Browse Source

Merge branch 'develop' into l10n_develop_2

pull/7675/head
Raju Udava 7 months ago committed by GitHub
parent
commit
bd9ea33b0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      packages/nc-gui/assets/style.scss
  2. 35
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  3. 26
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  4. 319
      packages/nc-gui/components/dlg/ViewCreate.vue
  5. 32
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  6. 21
      packages/nc-gui/components/nc/Button.vue
  7. 219
      packages/nc-gui/components/nc/DateWeekSelector.vue
  8. 141
      packages/nc-gui/components/nc/MonthYearSelector.vue
  9. 49
      packages/nc-gui/components/shared-view/Calendar.vue
  10. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  11. 3
      packages/nc-gui/components/shared-view/Grid.vue
  12. 12
      packages/nc-gui/components/smartsheet/Cell.vue
  13. 2
      packages/nc-gui/components/smartsheet/Gallery.vue
  14. 37
      packages/nc-gui/components/smartsheet/Toolbar.vue
  15. 6
      packages/nc-gui/components/smartsheet/Topbar.vue
  16. 236
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  17. 742
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  18. 815
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  19. 101
      packages/nc-gui/components/smartsheet/calendar/RecordCard.vue
  20. 456
      packages/nc-gui/components/smartsheet/calendar/SideMenu.vue
  21. 50
      packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue
  22. 97
      packages/nc-gui/components/smartsheet/calendar/VRecordCard.vue
  23. 605
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  24. 783
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  25. 31
      packages/nc-gui/components/smartsheet/calendar/YearView.vue
  26. 320
      packages/nc-gui/components/smartsheet/calendar/index.vue
  27. 90
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  28. 3
      packages/nc-gui/components/smartsheet/grid/index.vue
  29. 103
      packages/nc-gui/components/smartsheet/toolbar/CalendarMode.vue
  30. 237
      packages/nc-gui/components/smartsheet/toolbar/CalendarRange.vue
  31. 52
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  32. 21
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  33. 8
      packages/nc-gui/components/tabs/Smartsheet.vue
  34. 879
      packages/nc-gui/composables/useCalendarViewStore.ts
  35. 57
      packages/nc-gui/composables/useSharedView.ts
  36. 2
      packages/nc-gui/composables/useSmartsheetStore.ts
  37. 2
      packages/nc-gui/context/index.ts
  38. 2
      packages/nc-gui/lang/en.json
  39. 15
      packages/nc-gui/lib/types.ts
  40. 34
      packages/nc-gui/pages/index/[typeOrId]/calendar/[viewId]/index.vue
  41. 6
      packages/nc-gui/plugins/a.dayjs.ts
  42. 18
      packages/nc-gui/utils/browserUtils.ts
  43. 8
      packages/nc-gui/utils/dataUtils.ts
  44. 4
      packages/nc-gui/utils/generateName.ts
  45. 2
      packages/nc-gui/utils/iconUtils.ts
  46. 5
      packages/nc-gui/utils/viewUtils.ts
  47. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/010.overview.md
  48. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/010.okta.md
  49. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/020.auth0.md
  50. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/030.ping-identity.md
  51. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/040.azure-ad.md
  52. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/050.keycloak.md
  53. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/010.okta.md
  54. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/020.auth0.md
  55. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/030.ping-identity.md
  56. 4
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/040.azure-ad.md
  57. 1
      packages/nocodb-sdk/src/lib/globals.ts
  58. 21
      packages/nocodb/src/controllers/calendars.controller.spec.ts
  59. 70
      packages/nocodb/src/controllers/calendars.controller.ts
  60. 25
      packages/nocodb/src/controllers/data-alias.controller.ts
  61. 1
      packages/nocodb/src/controllers/data-table.controller.ts
  62. 15
      packages/nocodb/src/controllers/public-datas.controller.ts
  63. 158
      packages/nocodb/src/db/conditionV2.ts
  64. 42
      packages/nocodb/src/helpers/getAst.ts
  65. 12
      packages/nocodb/src/meta/meta.service.ts
  66. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  67. 66
      packages/nocodb/src/meta/migrations/v2/nc_041_calendar_view.ts
  68. 6
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  69. 122
      packages/nocodb/src/models/CalendarRange.ts
  70. 135
      packages/nocodb/src/models/CalendarView.ts
  71. 182
      packages/nocodb/src/models/CalendarViewColumn.ts
  72. 9
      packages/nocodb/src/models/FormViewColumn.ts
  73. 526
      packages/nocodb/src/models/View.ts
  74. 3
      packages/nocodb/src/models/index.ts
  75. 33
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  76. 5
      packages/nocodb/src/modules/metas/metas.module.ts
  77. 77
      packages/nocodb/src/schema/swagger-v2.json
  78. 687
      packages/nocodb/src/schema/swagger.json
  79. 93
      packages/nocodb/src/services/calendars.service.ts
  80. 10
      packages/nocodb/src/services/columns.service.ts
  81. 4
      packages/nocodb/src/services/data-table.service.ts
  82. 124
      packages/nocodb/src/services/datas.service.ts
  83. 84
      packages/nocodb/src/services/public-datas.service.ts
  84. 2
      packages/nocodb/src/utils/acl.ts
  85. 9
      packages/nocodb/src/utils/globals.ts
  86. 11
      packages/nocodb/tests/unit/factory/view.ts
  87. 312
      packages/nocodb/tests/unit/rest/tests/viewRow.test.ts
  88. 26
      tests/playwright/pages/Dashboard/Calendar/CalendarDayDate.ts
  89. 52
      tests/playwright/pages/Dashboard/Calendar/CalendarDayDateTime.ts
  90. 51
      tests/playwright/pages/Dashboard/Calendar/CalendarMonth.ts
  91. 43
      tests/playwright/pages/Dashboard/Calendar/CalendarSideMenu.ts
  92. 63
      tests/playwright/pages/Dashboard/Calendar/CalendarTopBar.ts
  93. 45
      tests/playwright/pages/Dashboard/Calendar/CalendarWeekDate.ts
  94. 59
      tests/playwright/pages/Dashboard/Calendar/CalendarWeekDateTime.ts
  95. 26
      tests/playwright/pages/Dashboard/Calendar/CalendarYear.ts
  96. 63
      tests/playwright/pages/Dashboard/Calendar/index.ts
  97. 25
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  98. 28
      tests/playwright/pages/Dashboard/Sidebar/index.ts
  99. 19
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  100. 44
      tests/playwright/pages/Dashboard/common/Toolbar/CalendarRange.ts
  101. Some files were not shown because too many files have changed in this diff Show More

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

@ -545,6 +545,13 @@ a {
@apply bg-gray-300 bg-opacity-20;
}
.ant-select-item-option:hover{
@apply !bg-gray-100;
}
.ant-select-item-option-selected{
@apply !bg-white;
}
/* Hide the element with id nc-selected-item-icon */
.ant-select-selection-item #nc-selected-item-icon {
@apply hidden;

35
packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
@ -36,11 +36,16 @@ async function onOpenModal({
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
}) {
if (isViewListLoading.value) return
@ -62,6 +67,7 @@ async function onOpenModal({
type,
'tableId': table.value.id,
'selectedViewId': copyViewId,
calendarRange,
groupingFieldColumnId,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
@ -97,13 +103,21 @@ async function onOpenModal({
close(1000)
}
}
const isEasterEggEnabled = ref(false)
watch(isOpen, (val) => {
if (!val) {
isEasterEggEnabled.value = false
}
})
</script>
<template>
<NcDropdown v-model:visible="isOpen" destroy-popup-on-hide :overlay-class-name="overlayClassName" @click.stop="isOpen = true">
<NcDropdown v-model:visible="isOpen" :overlay-class-name="overlayClassName" destroy-popup-on-hide @click.stop="isOpen = true">
<slot />
<template #overlay>
<NcMenu class="max-w-48">
<NcMenu class="max-w-48" @dblclick.stop="isEasterEggEnabled = true">
<NcMenuItem @click.stop="onOpenModal({ type: ViewTypes.GRID })">
<div class="item" data-testid="sidebar-view-create-grid">
<div class="item-inner">
@ -149,6 +163,21 @@ async function onOpenModal({
<GeneralIcon v-else class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem
v-if="isEasterEggEnabled"
data-testid="sidebar-view-create-calendar"
@click="onOpenModal({ type: ViewTypes.CALENDAR })"
>
<div class="item">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.CALENDAR }" />
<div>{{ $t('objects.viewType.calendar') }}</div>
</div>
<GeneralLoader v-if="toBeCreateType === ViewTypes.CALENDAR && isViewListLoading" />
<GeneralIcon v-else class="text-brand-400" icon="plus" />
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>

26
packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -350,11 +350,16 @@ function onOpenModal({
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
}) {
const isOpen = ref(true)
@ -366,6 +371,7 @@ function onOpenModal({
'selectedViewId': copyViewId,
groupingFieldColumnId,
'views': views,
calendarRange,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
@ -400,24 +406,24 @@ function onOpenModal({
<a-menu
ref="menuRef"
:class="{ dragging }"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
:selected-keys="selected"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
>
<DashboardTreeViewCreateViewBtn
v-if="isUIAllowed('viewCreateOrEdit')"
:align-left-level="isDefaultSource ? 1 : 2"
:class="{
'!pl-18 !xs:(pl-19.75)': isDefaultSource,
'!pl-23.5 !xs:(pl-27)': !isDefaultSource,
}"
:align-left-level="isDefaultSource ? 1 : 2"
>
<div
role="button"
class="nc-create-view-btn flex flex-row items-center cursor-pointer rounded-md w-full"
:class="{
'text-brand-500 hover:text-brand-600': activeTableId === table.id,
'text-gray-500 hover:text-brand-500': activeTableId !== table.id,
}"
class="nc-create-view-btn flex flex-row items-center cursor-pointer rounded-md w-full"
role="button"
>
<div class="flex flex-row items-center pl-1.25 !py-1.5 text-inherit">
<GeneralIcon icon="plus" />
@ -437,20 +443,20 @@ function onOpenModal({
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
:on-validate="validate"
:table="table"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{
'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id,
[`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
}"
:data-view-id="view.id"
@change-view="changeView"
@open-modal="onOpenModal"
:on-validate="validate"
:table="table"
:view="view"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
@delete="openDeleteDialog"
@rename="onRename"
@change-view="changeView"
@open-modal="onOpenModal"
@select-icon="setIcon($event, view)"
/>
</template>

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

@ -1,9 +1,9 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import { capitalize } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType, MapType, TableType } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk'
import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import type { CalendarType, FormType, GalleryType, GridType, KanbanType, MapType, TableType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isSystemColumn } from 'nocodb-sdk'
import { computed, message, nextTick, onBeforeMount, reactive, ref, useApi, useI18n, useVModel, watch } from '#imports'
interface Props {
@ -14,11 +14,16 @@ interface Props {
groupingFieldColumnId?: string
geoDataFieldColumnId?: string
tableId: string
calendarRange: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType | CalendarType): void
}
interface Form {
@ -28,12 +33,19 @@ interface Form {
// for kanban view only
fk_grp_col_id: string | null
fk_geo_data_col_id: string | null
// for calendar view only
calendar_range: Array<{
fk_from_column_id: string
fk_to_column_id: string | undefined // for ee only
}>
}
const props = withDefaults(defineProps<Props>(), {
selectedViewId: undefined,
groupingFieldColumnId: undefined,
geoDataFieldColumnId: undefined,
calendarRange: undefined,
})
const emits = defineEmits<Emits>()
@ -62,12 +74,21 @@ const isViewCreating = ref(false)
const views = computed(() => viewsByTable.value.get(tableId.value) ?? [])
const isNecessaryColumnsPresent = ref(true)
const errorMessages = {
[ViewTypes.KANBAN]: t('msg.warning.kanbanNoFields'),
[ViewTypes.MAP]: t('msg.warning.mapNoFields'),
[ViewTypes.CALENDAR]: t('msg.warning.calendarNoFields'),
}
const form = reactive<Form>({
title: props.title || '',
type: props.type,
copy_from_id: null,
fk_grp_col_id: null,
fk_geo_data_col_id: null,
calendar_range: props.calendarRange || [],
})
const viewSelectFieldOptions = ref<SelectProps['options']>([])
@ -97,6 +118,7 @@ const typeAlias = computed(
[ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
[ViewTypes.CALENDAR]: 'calendar',
}[props.type]),
)
@ -163,6 +185,16 @@ async function onSubmit() {
break
case ViewTypes.MAP:
data = await api.dbView.mapCreate(tableId.value, form)
break
case ViewTypes.CALENDAR:
data = await api.dbView.calendarCreate(tableId.value, {
...form,
calendar_range: form.calendar_range.map((range) => ({
fk_from_column_id: range.fk_from_column_id,
fk_to_column_id: range.fk_to_column_id,
})),
})
break
}
if (data) {
@ -174,7 +206,7 @@ async function onSubmit() {
} catch (e: any) {
message.error(e.message)
} finally {
refreshCommandPalette()
await refreshCommandPalette()
}
vModel.value = false
@ -185,10 +217,17 @@ async function onSubmit() {
}
}
const addCalendarRange = async () => {
form.calendar_range.push({
fk_from_column_id: viewSelectFieldOptions.value[0].value as string,
fk_to_column_id: null,
})
}
const isMetaLoading = ref(false)
onMounted(async () => {
if (props.type === ViewTypes.KANBAN || props.type === ViewTypes.MAP) {
if (props.type === ViewTypes.KANBAN || props.type === ViewTypes.MAP || props.type === ViewTypes.CALENDAR) {
isMetaLoading.value = true
try {
meta.value = (await getMeta(tableId.value))!
@ -206,9 +245,12 @@ onMounted(async () => {
if (geoDataFieldColumnId.value) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId.value
} else {
// take the first option
} else if (viewSelectFieldOptions.value?.length) {
// if there is geo data column take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
} else {
// if there is no geo data column, disable the create button
isNecessaryColumnsPresent.value = false
}
}
@ -226,9 +268,39 @@ onMounted(async () => {
if (groupingFieldColumnId.value) {
// take from the one from copy view
form.fk_grp_col_id = groupingFieldColumnId.value
} else if (viewSelectFieldOptions.value?.length) {
// take the first option
form.fk_grp_col_id = viewSelectFieldOptions.value[0].value as string
} else {
// if there is no grouping field column, disable the create button
isNecessaryColumnsPresent.value = false
}
}
if (props.type === ViewTypes.CALENDAR) {
viewSelectFieldOptions.value = meta
.value!.columns!.filter((el) => el.uidt === UITypes.Date || (el.uidt === UITypes.DateTime && !isSystemColumn(el)))
.map((field) => {
return {
value: field.id,
label: field.title,
uidt: field.uidt,
}
})
if (viewSelectFieldOptions.value?.length) {
// take the first option
form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
if (form.calendar_range.length === 0) {
form.calendar_range = [
{
fk_from_column_id: viewSelectFieldOptions.value[0].value as string,
fk_to_column_id: null, // for ee only
},
]
}
} else {
// if there is no grouping field column, disable the create button
isNecessaryColumnsPresent.value = false
}
}
} catch (e) {
@ -241,97 +313,214 @@ onMounted(async () => {
</script>
<template>
<NcModal v-model:visible="vModel" size="small">
<NcModal
v-model:visible="vModel"
:size="[ViewTypes.KANBAN, ViewTypes.MAP, ViewTypes.CALENDAR].includes(form.type) ? 'medium' : 'small'"
>
<template #header>
<div class="flex flex-row items-center gap-x-1.5">
<GeneralViewIcon :meta="{ type: form.type }" class="nc-view-icon !text-xl" />
<template v-if="form.type === ViewTypes.GRID">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateGridView') }}
</template>
<template v-else>
{{ $t('labels.createGridView') }}
</template>
</template>
<template v-else-if="form.type === ViewTypes.GALLERY">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateGalleryView') }}
</template>
<template v-else>
{{ $t('labels.createGalleryView') }}
<div class="flex w-full flex-row justify-between items-center">
<div class="flex gap-x-1.5 items-center">
<GeneralViewIcon :meta="{ type: form.type }" class="nc-view-icon !text-xl" />
<template v-if="form.type === ViewTypes.GRID">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateGridView') }}
</template>
<template v-else>
{{ $t('labels.createGridView') }}
</template>
</template>
</template>
<template v-else-if="form.type === ViewTypes.FORM">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateFormView') }}
<template v-else-if="form.type === ViewTypes.GALLERY">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateGalleryView') }}
</template>
<template v-else>
{{ $t('labels.createGalleryView') }}
</template>
</template>
<template v-else>
{{ $t('labels.createFormView') }}
<template v-else-if="form.type === ViewTypes.FORM">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateFormView') }}
</template>
<template v-else>
{{ $t('labels.createFormView') }}
</template>
</template>
</template>
<template v-else-if="form.type === ViewTypes.KANBAN">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateKanbanView') }}
<template v-else-if="form.type === ViewTypes.KANBAN">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateKanbanView') }}
</template>
<template v-else>
{{ $t('labels.createKanbanView') }}
</template>
</template>
<template v-else>
{{ $t('labels.createKanbanView') }}
</template>
</template>
<template v-else>
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateMapView') }}
<template v-else-if="form.type === ViewTypes.CALENDAR">
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateCalendarView') }}
</template>
<template v-else>
{{ $t('labels.createCalendarView') }}
</template>
</template>
<template v-else>
{{ $t('labels.duplicateView') }}
<template v-if="form.copy_from_id">
{{ $t('labels.duplicateMapView') }}
</template>
<template v-else>
{{ $t('labels.duplicateView') }}
</template>
</template>
</template>
</div>
<a
v-if="!form.copy_from_id"
class="text-sm !text-gray-600 !hover:text-gray-600"
href="https://docs.nocodb.com/views/view-types/calendar/"
target="_blank"
>
Go to Docs
</a>
</div>
</template>
<div class="mt-2">
<a-form ref="formValidator" layout="vertical" :model="form">
<a-form-item name="title" :rules="viewNameRules">
<a-form v-if="isNecessaryColumnsPresent" ref="formValidator" :model="form" layout="vertical">
<a-form-item :rules="viewNameRules" name="title">
<a-input
ref="inputEl"
v-model:value="form.title"
class="nc-input-md"
autofocus
:placeholder="$t('labels.viewName')"
autofocus
class="nc-input-md h-10"
@keydown.enter="onSubmit"
/>
</a-form-item>
<a-form-item
v-if="form.type === ViewTypes.KANBAN"
:label="$t('general.groupingField')"
name="fk_grp_col_id"
:rules="groupingFieldColumnRules"
name="fk_grp_col_id"
>
<NcSelect
v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:not-found-content="$t('placeholder.selectGroupFieldNotFound')"
:options="viewSelectFieldOptions"
:placeholder="$t('placeholder.selectGroupField')"
:not-found-content="$t('placeholder.selectGroupFieldNotFound')"
class="w-full nc-kanban-grouping-field-select"
/>
</a-form-item>
<a-form-item
v-if="form.type === ViewTypes.MAP"
:label="$t('general.geoDataField')"
name="fk_geo_data_col_id"
:rules="geoDataFieldColumnRules"
name="fk_geo_data_col_id"
>
<NcSelect
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:placeholder="$t('placeholder.selectGeoField')"
:not-found-content="$t('placeholder.selectGeoFieldNotFound')"
:options="viewSelectFieldOptions"
:placeholder="$t('placeholder.selectGeoField')"
class="w-full"
/>
</a-form-item>
<template v-if="form.type === ViewTypes.CALENDAR">
<div v-for="(range, index) in form.calendar_range" :key="`range-${index}`" class="flex w-full mb-2 items-center gap-2">
<span>
{{ $t('labels.organiseBy') }}
</span>
<NcSelect v-model:value="range.fk_from_column_id" :disabled="isMetaLoading" :loading="isMetaLoading">
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions!].filter((f) => {
// If the fk_from_column_id of first range is Date, then all the other ranges should be Date
// If the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
if (index === 0) return true
const firstRange = viewSelectFieldOptions!.find((f) => f.value === form.calendar_range[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
})"
:key="id"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
<div
v-if="range.fk_to_column_id === null && isEeUI && false"
class="cursor-pointer flex items-center text-gray-800 gap-1"
@click="range.fk_to_column_id = undefined"
>
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.addEndDate') }}
</div>
<template v-else-if="isEeUI && false">
<span>
{{ $t('activity.withEndDate') }}
</span>
<div class="flex">
<NcSelect
v-model:value="range.fk_to_column_id"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:placeholder="$t('placeholder.notSelected')"
class="!rounded-r-none nc-to-select"
>
<a-select-option
v-for="(option, id) in [...viewSelectFieldOptions].filter((f) => {
// If the fk_from_column_id of first range is Date, then all the other ranges should be Date
// If the fk_from_column_id of first range is DateTime, then all the other ranges should be DateTime
const firstRange = viewSelectFieldOptions.find((f) => f.value === form.calendar_range[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
})"
:key="id"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="range.fk_to_column_id = null">
<component :is="iconMap.delete" class="h-4 w-4" />
</NcButton>
</div>
</template>
<NcButton
v-if="index !== 0"
size="small"
type="secondary"
@click="
() => {
form.calendar_range = form.calendar_range.filter((_, i) => i !== index)
}
"
>
<component :is="iconMap.close" />
</NcButton>
</div>
<NcButton v-if="false" class="mt-2" size="small" type="secondary" @click="addCalendarRange">
<component :is="iconMap.plus" />
Add another date field
</NcButton>
</template>
</a-form>
<div v-else-if="!isNecessaryColumnsPresent" class="flex flex-row p-4 border-gray-200 border-1 gap-x-4 rounded-lg w-full">
<GeneralIcon class="!text-5xl text-orange-500" icon="warning" />
<div class="text-gray-500">
<h2 class="font-semibold text-sm text-gray-800">Suitable fields not present</h2>
{{ errorMessages[form.type] }}
</div>
</div>
<div class="flex flex-row w-full justify-end gap-x-2 mt-7">
<NcButton type="secondary" @click="vModel = false">
@ -340,8 +529,9 @@ onMounted(async () => {
<NcButton
v-e="[form.copy_from_id ? 'a:view:duplicate' : 'a:view:create']"
type="primary"
:disabled="!isNecessaryColumnsPresent"
:loading="isViewCreating"
type="primary"
@click="onSubmit"
>
{{ $t('labels.createView') }}
@ -351,3 +541,16 @@ onMounted(async () => {
</div>
</NcModal>
</template>
<style lang="scss">
.ant-form-item-required {
@apply !text-gray-800 font-medium;
&:before {
@apply !content-[''];
}
}
.nc-to-select .ant-select-selector {
@apply !rounded-r-none;
}
</style>

32
packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue

@ -163,6 +163,9 @@ function sharedViewUrl() {
case ViewTypes.MAP:
viewType = 'map'
break
case ViewTypes.CALENDAR:
viewType = 'calendar'
break
default:
viewType = 'view'
}
@ -280,11 +283,11 @@ const isPublicShared = computed(() => {
<div class="text-gray-900 font-medium">{{ $t('activity.enabledPublicViewing') }}</div>
<a-switch
v-e="['c:share:view:enable:toggle']"
data-testid="share-view-toggle"
:checked="isPublicShared"
:disabled="isLocked"
:loading="isUpdating.public"
class="share-view-toggle !mt-0.25"
:disabled="isLocked"
data-testid="share-view-toggle"
@click="toggleShare"
/>
</div>
@ -297,22 +300,22 @@ const isPublicShared = computed(() => {
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
<a-switch
v-e="['c:share:view:password:toggle']"
data-testid="share-password-toggle"
:checked="passwordProtected"
:loading="isUpdating.password"
class="share-password-toggle !mt-0.25"
data-testid="share-password-toggle"
@click="togglePasswordProtected"
/>
</div>
<Transition name="layout" mode="out-in">
<Transition mode="out-in" name="layout">
<div v-if="passwordProtected" class="flex gap-2 mt-2 w-2/3">
<a-input-password
v-model:value="password"
data-testid="nc-modal-share-view__password"
:placeholder="$t('placeholder.password.enter')"
class="!rounded-lg !py-1 !bg-white"
data-testid="nc-modal-share-view__password"
size="small"
type="password"
:placeholder="$t('placeholder.password.enter')"
/>
</div>
</Transition>
@ -324,7 +327,8 @@ const isPublicShared = computed(() => {
(activeView.type === ViewTypes.GRID ||
activeView.type === ViewTypes.KANBAN ||
activeView.type === ViewTypes.GALLERY ||
activeView.type === ViewTypes.MAP)
activeView.type === ViewTypes.MAP ||
activeView.type === ViewTypes.CALENDAR)
"
class="flex flex-row justify-between"
>
@ -332,9 +336,9 @@ const isPublicShared = computed(() => {
<a-switch
v-model:checked="allowCSVDownload"
v-e="['c:share:view:allow-csv-download:toggle']"
data-testid="share-download-toggle"
:loading="isUpdating.download"
class="public-password-toggle !mt-0.25"
data-testid="share-download-toggle"
/>
</div>
@ -361,23 +365,23 @@ const isPublicShared = computed(() => {
<div class="text-black">{{ $t('activity.useTheme') }}</div>
<a-switch
v-e="['c:share:view:theme:toggle']"
data-testid="share-theme-toggle"
:checked="viewTheme"
:loading="isUpdating.password"
class="share-theme-toggle !mt-0.25"
data-testid="share-theme-toggle"
@click="viewTheme = !viewTheme"
/>
</div>
<Transition name="layout" mode="out-in">
<Transition mode="out-in" name="layout">
<div v-if="viewTheme" class="flex -ml-1">
<LazyGeneralColorPicker
data-testid="nc-modal-share-view__theme-picker"
class="!p-0 !bg-inherit"
:model-value="activeView?.meta?.theme?.primaryColor"
:advanced="false"
:colors="baseThemeColors"
:model-value="activeView?.meta?.theme?.primaryColor"
:row-size="9"
:advanced="false"
class="!p-0 !bg-inherit"
data-testid="nc-modal-share-view__theme-picker"
@input="onChangeTheme"
/>
</div>

21
packages/nc-gui/components/nc/Button.vue

@ -20,6 +20,7 @@ interface Props {
type?: ButtonType | 'danger' | 'secondary' | undefined
size?: NcButtonSize
centered?: boolean
fullWidth?: boolean
iconOnly?: boolean
}
@ -27,6 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
size: 'medium',
type: 'primary',
fullWidth: false,
centered: true,
})
@ -73,10 +75,6 @@ useEventListener(NcButton, 'mousedown', () => {
<template>
<a-button
ref="NcButton"
:disabled="props.disabled"
:loading="loading"
:type="type"
class="nc-button"
:class="{
small: size === 'small',
medium: size === 'medium',
@ -84,34 +82,39 @@ useEventListener(NcButton, 'mousedown', () => {
xxsmall: size === 'xxsmall',
focused: isFocused,
}"
:disabled="props.disabled"
:loading="loading"
:tabindex="props.disabled ? -1 : 0"
@focus="onFocus"
:type="type"
class="nc-button"
@blur="onBlur"
@focus="onFocus"
>
<div
class="flex flex-row gap-x-2.5 w-full"
:class="{
'justify-center': props.centered,
'justify-start': !props.centered,
}"
class="flex flex-row gap-x-2.5 w-full"
>
<GeneralLoader
v-if="loading"
size="medium"
class="flex !bg-inherit"
:class="{
'!text-white': type === 'primary' || type === 'danger',
'!text-gray-800': type !== 'primary' && type !== 'danger',
}"
class="flex !bg-inherit"
size="medium"
/>
<slot v-else name="icon" />
<div
v-if="!(size === 'xxsmall' && loading) && !props.iconOnly"
class="flex flex-row items-center"
:class="{
'font-medium': type === 'primary' || type === 'danger',
'w-full': props.fullWidth,
}"
class="flex flex-row items-center"
>
<slot v-if="loading && slots.loading" name="loading" />

219
packages/nc-gui/components/nc/DateWeekSelector.vue

@ -0,0 +1,219 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
interface Props {
selectedDate?: dayjs.Dayjs | null
isDisabled?: boolean
pageDate?: dayjs.Dayjs
activeDates?: Array<dayjs.Dayjs>
isMondayFirst?: boolean
isWeekPicker?: boolean
disablePagination?: boolean
selectedWeek?: {
start: dayjs.Dayjs
end: dayjs.Dayjs
} | null
}
const props = withDefaults(defineProps<Props>(), {
selectedDate: null,
isDisabled: false,
isMondayFirst: true,
pageDate: dayjs(),
isWeekPicker: false,
disablePagination: false,
activeDates: [] as Array<dayjs.Dayjs>,
selectedWeek: null,
})
const emit = defineEmits(['change', 'update:selectedDate', 'update:pageDate', 'update:selectedWeek'])
// Page date is the date we use to manage which month/date that is currently being displayed
const pageDate = useVModel(props, 'pageDate', emit)
const selectedDate = useVModel(props, 'selectedDate', emit)
const activeDates = useVModel(props, 'activeDates', emit)
const selectedWeek = useVModel(props, 'selectedWeek', emit)
const days = computed(() => {
if (props.isMondayFirst) {
return ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
} else {
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
}
})
// Used to display the current month and year
const currentMonthYear = computed(() => {
return dayjs(pageDate.value).format('MMMM YYYY')
})
const selectWeek = (date: dayjs.Dayjs) => {
const dayOffset = +props.isMondayFirst
const dayOfWeek = (date.day() - dayOffset + 7) % 7
const startDate = date.subtract(dayOfWeek, 'day')
selectedWeek.value = {
start: startDate,
end: startDate.endOf('week'),
}
}
// Generates all dates should be displayed in the calendar
// Includes all blank days at the start and end of the month
const dates = computed(() => {
const startOfMonth = dayjs(pageDate.value).startOf('month')
const dayOffset = +props.isMondayFirst
const firstDayOfWeek = startOfMonth.day()
const startDay = startOfMonth.subtract((firstDayOfWeek - dayOffset + 7) % 7, 'day')
const datesArray = []
for (let i = 0; i < 42; i++) {
datesArray.push(startDay.add(i, 'day'))
}
return datesArray
})
// Check if the date is in the selected week
const isDateInSelectedWeek = (date: dayjs.Dayjs) => {
if (!selectedWeek.value) return false
return date.isBetween(selectedWeek.value.start, selectedWeek.value.end, 'day', '[]')
}
// Used to check if two dates are the same
const isSameDate = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
if (!date1 || !date2) return false
return date1.isSame(date2, 'day')
}
// Used in DatePicker for checking if the date is currently selected
const isSelectedDate = (dObj: dayjs.Dayjs) => {
if (!selectedDate.value) return false
const propDate = dayjs(selectedDate.value)
return props.selectedDate ? isSameDate(propDate, dObj) : false
}
const isDayInPagedMonth = (date: dayjs.Dayjs) => {
return date.month() === dayjs(pageDate.value).month()
}
// Since we are using the same component for week picker and date picker we need to handle the date selection differently
const handleSelectDate = (date: dayjs.Dayjs) => {
if (props.isWeekPicker) {
selectWeek(date)
} else {
if (!isDayInPagedMonth(date)) {
pageDate.value = date
emit('update:pageDate', date)
}
selectedDate.value = date
emit('update:selectedDate', date)
}
}
// Used to check if a date is in the current month
const isDateInCurrentMonth = (date: dayjs.Dayjs) => {
return date.month() === dayjs(pageDate.value).month()
}
// Used to Check if an event is in the date
const isActiveDate = (date: dayjs.Dayjs) => {
return activeDates.value.some((d) => isSameDate(d, date))
}
// Paginate the calendar
const paginate = (action: 'next' | 'prev') => {
let newDate = dayjs(pageDate.value)
if (action === 'next') {
newDate = newDate.add(1, 'month')
} else {
newDate = newDate.subtract(1, 'month')
}
pageDate.value = newDate
emit('update:pageDate', newDate)
}
</script>
<template>
<div class="px-4 pt-3 pb-4 flex flex-col gap-4">
<div
:class="{
'justify-center': disablePagination,
'justify-between': !disablePagination,
}"
class="flex items-center"
>
<NcTooltip>
<NcButton v-if="!disablePagination" size="small" type="secondary" @click="paginate('prev')">
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" />
</NcButton>
<template #title>
<span>{{ $t('labels.previousMonth') }}</span>
</template>
</NcTooltip>
<span class="font-bold text-gray-700">{{ currentMonthYear }}</span>
<NcTooltip>
<NcButton v-if="!disablePagination" size="small" type="secondary" @click="paginate('next')">
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" />
</NcButton>
<template #title>
<span>{{ $t('labels.nextMonth') }}</span>
</template>
</NcTooltip>
</div>
<div class="border-1 border-gray-200 rounded-y-xl max-w-[320px]">
<div class="flex flex-row bg-gray-100 gap-2 rounded-t-xl justify-between px-2">
<span v-for="(day, index) in days" :key="index" class="h-9 flex items-center justify-center w-9 text-gray-500">{{
day
}}</span>
</div>
<div class="grid grid-cols-7 gap-2 p-2">
<span
v-for="(date, index) in dates"
:key="index"
:class="{
'rounded-lg': !isWeekPicker,
'bg-brand-50 border-1 !border-brand-500': isSelectedDate(date) && !isWeekPicker && isDayInPagedMonth(date),
'hover:(border-1 border-gray-200 bg-gray-100)': !isSelectedDate(date) && !isWeekPicker,
'nc-selected-week z-1': isDateInSelectedWeek(date) && isWeekPicker,
'text-gray-400': !isDateInCurrentMonth(date),
'nc-selected-week-start': isSameDate(date, selectedWeek?.start),
'nc-selected-week-end': isSameDate(date, selectedWeek?.end),
'rounded-md bg-brand-50 text-brand-500': isSameDate(date, dayjs()) && isDateInCurrentMonth(date),
}"
class="h-9 w-9 px-1 py-2 relative font-medium flex items-center cursor-pointer justify-center"
data-testid="nc-calendar-date"
@click="handleSelectDate(date)"
>
<span v-if="isActiveDate(date)" class="absolute z-2 h-1.5 w-1.5 rounded-full bg-brand-500 top-1 right-1"></span>
<span class="z-2">
{{ date.get('date') }}
</span>
</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.nc-selected-week {
@apply relative;
}
.nc-selected-week:before {
@apply absolute top-0 left-0 w-full h-full border-y-1 bg-brand-50 border-brand-500;
content: '';
width: 124%;
height: 100%;
}
.nc-selected-week-start:before {
@apply !border-l-1 !rounded-l-lg;
}
.nc-selected-week-end:before {
width: 100%;
@apply !border-r-1 !rounded-r-lg;
}
</style>

141
packages/nc-gui/components/nc/MonthYearSelector.vue

@ -0,0 +1,141 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
interface Props {
selectedDate?: dayjs.Dayjs | null
isDisabled?: boolean
pageDate?: dayjs.Dayjs
isYearPicker?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selectedDate: null,
isDisabled: false,
pageDate: dayjs(),
isYearPicker: false,
})
const emit = defineEmits(['update:selectedDate', 'update:pageDate'])
const pageDate = useVModel(props, 'pageDate', emit)
const selectedDate = useVModel(props, 'selectedDate', emit)
const years = computed(() => {
const date = pageDate.value
const startOfYear = date.startOf('year')
const years: dayjs.Dayjs[] = []
for (let i = 0; i < 12; i++) {
years.push(dayjs(startOfYear).add(i, 'year'))
}
return years
})
const months = computed(() => {
const months: dayjs.Dayjs[] = []
for (let i = 0; i < 12; i++) {
months.push(pageDate.value.set('month', i))
}
return months
})
const compareDates = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
if (!date1 || !date2) return false
return date1.isSame(date2, 'month') && date1.isSame(date2, 'year')
}
const isMonthSelected = (date: dayjs.Dayjs) => {
if (!dayjs(selectedDate.value).isValid()) return false
return compareDates(date, selectedDate.value)
}
const paginateMonth = (action: 'next' | 'prev') => {
let date = pageDate.value
if (action === 'next') {
date = date.add(1, 'year')
} else {
date = date.subtract(1, 'year')
}
pageDate.value = date
emit('update:pageDate', date)
}
const paginateYear = (action: 'next' | 'prev') => {
let date = dayjs(pageDate.value)
if (action === 'next') {
date = date.add(12, 'year')
} else {
date = date.subtract(12, 'year')
}
pageDate.value = date
emit('update:pageDate', date)
}
const paginate = (action: 'next' | 'prev') => {
if (props.isYearPicker) {
paginateYear(action)
} else {
paginateMonth(action)
}
}
const compareYear = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => {
if (!date1 || !date2) return false
return date1.isSame(date2, 'year')
}
</script>
<template>
<div class="px-4 pt-3 pb-4 flex flex-col gap-4">
<div class="flex justify-between items-center">
<NcTooltip>
<NcButton size="small" type="secondary" @click="paginate('prev')">
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" />
</NcButton>
<template #title>
<span>{{ $t('labels.previous') }}</span>
</template>
</NcTooltip>
<span class="font-bold text-gray-700">{{ isYearPicker ? $t('labels.selectYear') : pageDate.year() }}</span>
<NcTooltip>
<NcButton size="small" type="secondary" @click="paginate('next')">
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" />
</NcButton>
<template #title>
<span>{{ $t('labels.next') }}</span>
</template>
</NcTooltip>
</div>
<div class="rounded-y-xl max-w-[350px]">
<div class="grid grid-cols-4 gap-2 p-2">
<template v-if="!isYearPicker">
<span
v-for="(month, id) in months"
:key="id"
:class="{
'!bg-brand-50 border-1 !border-brand-500': isMonthSelected(month),
}"
class="h-9 rounded-lg flex font-medium items-center justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-500 cursor-pointer"
@click="selectedDate = month"
>
{{ month.format('MMM') }}
</span>
</template>
<template v-else>
<span
v-for="(year, id) in years"
:key="id"
:class="{
'!bg-brand-50 !border-1 !border-brand-500': compareYear(year, selectedDate),
}"
class="h-9 rounded-lg flex font-medium items-center justify-center hover:(border-1 border-gray-200 bg-gray-100) text-gray-500 cursor-pointer"
@click="selectedDate = year"
>
{{ year.format('YYYY') }}
</span>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

49
packages/nc-gui/components/shared-view/Calendar.vue

@ -0,0 +1,49 @@
<script lang="ts" setup>
import {
ActiveViewInj,
FieldsInj,
IsPublicInj,
MetaInj,
ReadonlyInj,
ReloadViewDataHookInj,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
} from '#imports'
const { sharedView, meta, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || []))
provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)
useProvideCalendarViewStore(meta, sharedView, true, nestedFilters)
</script>
<template>
<div class="nc-container h-full mt-1.5 px-12">
<div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">
<LazySmartsheetCalendar />
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

2
packages/nc-gui/components/shared-view/Gallery.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import {
ActiveViewInj,
FieldsInj,

3
packages/nc-gui/components/shared-view/Grid.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import {
ActiveViewInj,
FieldsInj,
@ -26,6 +26,7 @@ const { loadProject } = useBase()
const { isLocked } = useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)
useProvideCalendarViewStore(meta, sharedView)
const reloadEventHook = createEventHook()

12
packages/nc-gui/components/smartsheet/Cell.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import { isSystemColumn } from 'nocodb-sdk'
import {
@ -88,6 +88,8 @@ const isPublic = inject(IsPublicInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isCalendar = inject(IsCalendarInj, ref(false))
const isEditColumnMenu = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
@ -195,20 +197,20 @@ onUnmounted(() => {
<template>
<div
ref="elementToObserve"
class="nc-cell w-full h-full relative"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{
'text-brand-500': isPrimary(column) && !props.virtual && !isForm,
'text-brand-500': isPrimary(column) && !props.virtual && !isForm && !isCalendar,
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen,
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30 resize-y': isTextArea(column) && (isForm || isSurveyForm),
},
]"
class="nc-cell w-full h-full relative"
@contextmenu="onContextmenu"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
@contextmenu="onContextmenu"
>
<template v-if="column">
<template v-if="intersected">
@ -260,7 +262,7 @@ onUnmounted(() => {
</div>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
.nc-grid-numeric-cell-left {
text-align: left;
:deep(input) {

2
packages/nc-gui/components/smartsheet/Gallery.vue

@ -4,6 +4,7 @@ import type { Row as RowType } from '#imports'
import {
ActiveViewInj,
FieldsInj,
IsCalendarInj,
IsFormInj,
IsGalleryInj,
IsGridInj,
@ -57,6 +58,7 @@ const {
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(true))
provide(IsGridInj, ref(false))
provide(IsCalendarInj, ref(false))
provide(RowHeightInj, ref(1 as const))

37
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import {
IsPublicInj,
inject,
@ -10,7 +10,7 @@ import {
useViewsStore,
} from '#imports'
const { isGrid, isGallery, isKanban, isMap } = useSmartsheetStoreOrThrow()
const { isGrid, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
@ -18,28 +18,53 @@ const { isViewsLoading } = storeToRefs(useViewsStore())
const { isMobileMode } = useGlobal()
const containerRef = ref<HTMLElement>()
const isTab = ref(true)
const handleResize = () => {
isTab.value = containerRef.value.offsetWidth > 810
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
const { allowCSVDownload } = useSharedView()
</script>
<template>
<div
class="nc-table-toolbar py-1 px-2.25 xs:(px-1) flex gap-2 items-center border-b border-gray-200 overflow-hidden xs:(min-h-14) max-h-[var(--topbar-height)] min-h-[var(--topbar-height)] z-7"
v-if="!isMobileMode || !isCalendar"
ref="containerRef"
class="nc-table-toolbar relative py-1 px-2.25 xs:(px-1) flex gap-2 items-center border-b border-gray-200 overflow-hidden xs:(min-h-14) max-h-[var(--topbar-height)] min-h-[var(--topbar-height)] z-7"
>
<template v-if="isViewsLoading">
<a-skeleton-input :active="true" class="!w-44 !h-4 ml-2 !rounded overflow-hidden" />
</template>
<template v-else>
<LazySmartsheetToolbarMappedBy v-if="isMap" />
<LazySmartsheetToolbarCalendarRange v-if="isCalendar" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarFieldsMenu
v-if="isGrid || isGallery || isKanban || isMap || isCalendar"
:show-system-fields="false"
/>
<LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap || isCalendar" />
<LazySmartsheetToolbarGroupByMenu v-if="isGrid" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban || isCalendar" />
<div v-if="isCalendar && isTab" class="flex-1" />
<LazySmartsheetToolbarCalendarMode v-if="isCalendar" v-model:tab="isTab" />
<template v-if="!isMobileMode">
<LazySmartsheetToolbarRowHeight v-if="isGrid" />

6
packages/nc-gui/components/smartsheet/Topbar.vue

@ -1,7 +1,7 @@
<script setup lang="ts">
<script lang="ts" setup>
import { IsPublicInj, inject, ref, useSmartsheetStoreOrThrow, useViewsStore } from '#imports'
const { isGrid, isForm, isGallery, isKanban, isMap } = useSmartsheetStoreOrThrow()
const { isGrid, isForm, isGallery, isKanban, isMap, isCalendar } = useSmartsheetStoreOrThrow()
const router = useRouter()
const route = router.currentRoute
@ -37,7 +37,7 @@ const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
<GeneralApiLoader v-if="!isMobileMode" />
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap) && !isPublic && !isMobileMode"
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"
is-view-toolbar
/>

236
packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue

@ -0,0 +1,236 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { type Row, computed, ref } from '#imports'
import { isRowEmpty } from '~/utils'
const emit = defineEmits(['expand-record', 'new-record'])
const meta = inject(MetaInj, ref())
const container = ref()
const { isUIAllowed } = useRoles()
const { selectedDate, formattedData, formattedSideBarData, calendarRange, updateRowProperty, displayField } =
useCalendarViewStoreOrThrow()
// We loop through all the records and calculate the position of each record based on the range
// We only need to calculate the top, of the record since there is no overlap in the day view of date Field
const recordsAcrossAllRange = computed<Row[]>(() => {
let dayRecordCount = 0
const perRecordHeight = 40
if (!calendarRange.value) return []
const recordsByRange: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const endCol = range.fk_to_col
if (fromCol && endCol) {
for (const record of formattedData.value) {
const startDate = dayjs(record.row[fromCol.title!])
const endDate = dayjs(record.row[endCol.title!])
dayRecordCount++
const style: Partial<CSSStyleDeclaration> = {
top: `${(dayRecordCount - 1) * perRecordHeight}px`,
width: '100%',
}
// This property is used to determine which side the record should be rounded. It can be left, right, both or none
let position = 'none'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day')
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(selectedDate.value, 'day')
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(selectedDate.value, 'day')
if (isSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rounded'
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'none'
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'leftRounded'
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rightRounded'
} else {
position = 'none'
}
recordsByRange.push({
...record,
rowMeta: {
...record.rowMeta,
position,
style,
range: range as any,
},
})
}
} else if (fromCol) {
for (const record of formattedData.value) {
dayRecordCount++
recordsByRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
style: {
width: '100%',
left: '0',
top: `${(dayRecordCount - 1) * perRecordHeight}px`,
},
position: 'rounded',
},
})
}
}
})
return recordsByRange
})
const dragElement = ref<HTMLElement | null>(null)
const hoverRecord = ref<string | null>(null)
// We support drag and drop from the sidebar to the day view of the date field
const dropEvent = (event: DragEvent) => {
if (!isUIAllowed('dataEdit')) return
event.preventDefault()
const data = event.dataTransfer?.getData('text/plain')
if (data) {
const {
record,
}: {
record: Row
initialClickOffsetY: number
initialClickOffsetX: number
} = JSON.parse(data)
const fromCol = record.rowMeta.range?.fk_from_col
const toCol = record.rowMeta.range?.fk_to_col
if (!fromCol) return
const newStartDate = dayjs(selectedDate.value).startOf('day')
let endDate
const newRow = {
...record,
row: {
...record.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
const updateProperty = [fromCol.title!]
if (toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol.title!)
}
if (!newRow) return
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (dragElement.value) {
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
} else {
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
return extractPkFromRow(r.row, meta.value!.columns!) !== newPk
})
}
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
}
}
</script>
<template>
<div
v-if="recordsAcrossAllRange.length"
ref="container"
class="w-full relative h-[calc(100vh-10.8rem)] overflow-y-auto nc-scrollbar-md"
data-testid="nc-calendar-day-view"
@drop="dropEvent"
>
<div
v-for="(record, rowIndex) in recordsAcrossAllRange"
:key="rowIndex"
:style="record.rowMeta.style"
class="absolute mt-2"
data-testid="nc-calendar-day-record-card"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:position="record.rowMeta.position"
:record="record"
:resize="false"
color="blue"
size="small"
@click="emit('expand-record', record)"
>
<template v-if="!isRowEmpty(record, displayField)">
<div
:class="{
'!mt-1.5 ml-1': displayField.uidt === UITypes.SingleLineText,
'!mt-1': displayField.uidt === UITypes.MultiSelect || displayField.uidt === UITypes.SingleSelect,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField!)"
v-model="record.row[displayField!.title!]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField!.title!]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</div>
<div
v-else
ref="container"
class="w-full h-full flex text-md font-bold text-gray-500 items-center justify-center"
@drop="dropEvent"
>
No records in this day
</div>
</template>
<style lang="scss" scoped></style>

742
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -0,0 +1,742 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { type Row, computed, ref } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emit = defineEmits(['expandRecord', 'new-record'])
const {
selectedDate,
selectedTime,
formattedData,
calendarRange,
formattedSideBarData,
updateRowProperty,
displayField,
sideBarFilterOption,
showSideMenu,
} = useCalendarViewStoreOrThrow()
const container = ref<null | HTMLElement>(null)
const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref())
const hours = computed(() => {
const hours: Array<dayjs.Dayjs> = []
const _selectedDate = dayjs(selectedDate.value)
for (let i = 0; i < 24; i++) {
hours.push(_selectedDate.clone().startOf('day').add(i, 'hour'))
}
return hours
})
const recordsAcrossAllRange = computed<{
record: Row[]
count: {
[key: string]: {
id: string
overflow: boolean
overflowCount: number
}
}
}>(() => {
if (!calendarRange.value || !formattedData.value) return { record: [], count: {} }
const scheduleStart = dayjs(selectedDate.value).startOf('day')
const scheduleEnd = dayjs(selectedDate.value).endOf('day')
// We use this object to keep track of the number of records that overlap at a given time, and if the number of records exceeds 4, we hide the record
// and show a button to view more records
// The key is the time in HH:mm format
// id is the id of the record generated below
const overlaps: {
[key: string]: {
id: string[]
overflow: boolean
overflowCount: number
}
} = {}
const perRecordHeight = 80
let recordsByRange: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const endCol = range.fk_to_col
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value].filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && endCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[endCol.title!] ? dayjs(record.row[endCol.title!]) : null
return fromDate && toDate?.isValid() ? fromDate.isBefore(toDate) : true
} else if (fromCol && !endCol) {
return !!fromDate
}
return false
})
// If there is a start and end column, we calculate the top and height of the record based on the start and end date
if (fromCol && endCol) {
for (const record of sortedFormattedData) {
// We use this id during the drag and drop operation and to keep track of the number of records that overlap at a given time
const id = generateRandomNumber()
let startDate = dayjs(record.row[fromCol.title!])
let endDate = dayjs(record.row[endCol.title!])
// If no start date is provided or startDate is after the endDate, we skip the record
if (!startDate.isValid() || startDate.isAfter(endDate)) continue
// If there is no end date, we add 30 minutes to the start date and use that as the end date
if (!endDate.isValid()) {
endDate = startDate.clone().add(30, 'minutes')
}
// If the start date is before the opened date, we use the schedule start as the start date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart
}
// If the end date is after the schedule end, we use the schedule end as the end date
// This is to ensure the generated style of the record is not outside the bounds of the calendar
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
}
// The top of the record is calculated based on the start hour and minute
const topInPixels = (startDate.hour() + startDate.minute() / 60) * 80
// A minimum height of 80px is set for each record
// The height of the record is calculated based on the difference between the start and end date
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 80, perRecordHeight)
const startHour = startDate.hour()
let _startDate = startDate.clone()
const style: Partial<CSSStyleDeclaration> = {
height: `${heightInPixels}px`,
top: `${topInPixels + 5 + startHour * 2}px`,
}
// We loop through every 15 minutes between the start and end date and keep track of the number of records that overlap at a given time
// If the number of records exceeds 4, we hide the record and show a button to view more records
while (_startDate.isBefore(endDate)) {
const timeKey = _startDate.format('HH:mm')
if (!overlaps[timeKey]) {
overlaps[timeKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[timeKey].id.push(id)
// If the number of records exceeds 4, we hide the record mark the time as overflow
if (overlaps[timeKey].id.length > 4) {
overlaps[timeKey].overflow = true
style.display = 'none'
overlaps[timeKey].overflowCount += 1
}
_startDate = _startDate.add(15, 'minutes')
}
// This property is used to determine which side the record should be rounded. It can be top, bottom, both or none
// We use the start and end date to determine the position of the record
let position = 'none'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(selectedDate.value, 'day')
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(selectedDate.value, 'day')
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(selectedDate.value, 'day')
if (isSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rounded'
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'none'
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'topRounded'
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'bottomRounded'
} else {
position = 'none'
}
recordsByRange.push({
...record,
rowMeta: {
...record.rowMeta,
position,
style,
id,
range: range as any,
},
})
}
} else if (fromCol) {
for (const record of sortedFormattedData) {
const id = generateRandomNumber()
const startDate = dayjs(record.row[fromCol.title!])
const endDate = dayjs(record.row[fromCol.title!]).add(15, 'minutes')
const startHour = startDate.hour()
let style: Partial<CSSStyleDeclaration> = {}
let _startDate = startDate.clone()
// We loop through every 15 minutes between the start and end date and keep track of the number of records that overlap at a given time
while (_startDate.isBefore(endDate)) {
const timeKey = _startDate.startOf('hour').format('HH:mm')
if (!overlaps[timeKey]) {
overlaps[timeKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[timeKey].id.push(id)
// If the number of records exceeds 8, we hide the record and mark it as overflow
if (overlaps[timeKey].id.length > 8) {
overlaps[timeKey].overflow = true
overlaps[timeKey].overflowCount += 1
style = {
...style,
display: 'none',
}
}
_startDate = _startDate.add(15, 'minutes')
}
const topInPixels = (startDate.hour() + startDate.startOf('hour').minute() / 60) * 80
// A minimum height of 80px is set for each record
const heightInPixels = Math.max((endDate.diff(startDate, 'minute') / 60) * 80, perRecordHeight)
const finalTopInPixels = topInPixels + startHour * 2
style = {
...style,
top: `${finalTopInPixels + 2}px`,
height: `${heightInPixels - 2}px`,
}
recordsByRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
style,
id,
position: 'rounded',
},
})
}
}
})
// We can't calculate the width & left of the records without knowing the number of records that overlap at a given time
// So we loop through the records again and calculate the width & left of the records based on the number of records that overlap at a given time
recordsByRange = recordsByRange.map((record) => {
// MaxOverlaps is the number of records that overlap at a given time
// overlapIndex is the index of the record in the list of records that overlap at a given time
let maxOverlaps = 1
let overlapIndex = 0
for (const minutes in overlaps) {
if (overlaps[minutes].id.includes(record.rowMeta.id!)) {
maxOverlaps = Math.max(maxOverlaps, overlaps[minutes].id.length - overlaps[minutes].overflowCount)
overlapIndex = Math.max(overlaps[minutes].id.indexOf(record.rowMeta.id!), overlapIndex)
}
}
const spacing = 0.25
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps
const leftPerRecord = (widthPerRecord + spacing) * overlapIndex
record.rowMeta.style = {
...record.rowMeta.style,
left: `${leftPerRecord - 0.08}%`,
width: `calc(${widthPerRecord}%)`,
}
return record
})
return {
count: overlaps,
record: recordsByRange,
}
})
const dragRecord = ref<Row | null>(null)
const isDragging = ref(false)
const dragElement = ref<HTMLElement | null>(null)
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>()
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>(null)
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
// When the user is dragging a record, we calculate the new start and end date based on the mouse position
const calculateNewRow = (event: MouseEvent) => {
if (!container.value || !dragRecord.value) return { newRow: null, updateProperty: [] }
const { top } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
// We calculate the percentage of the mouse position in the scroll container
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
// We calculate the hour based on the percentage of the mouse position in the scroll container
// It can be between 0 and 23 (inclusive)
const hour = Math.max(Math.floor(percentY * 23), 0)
// We calculate the new startDate by adding the hour to the start of the selected date
const newStartDate = dayjs(selectedDate.value).startOf('day').add(hour, 'hour')
if (!newStartDate || !fromCol) return { newRow: null, updateProperty: [] }
let endDate
const newRow = {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
const updateProperty = [fromCol.title!]
if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null
// If there is an end date, we calculate the new end date based on the new start date and add the difference between the start and end date to the new start date
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'hour'), 'hour')
} else if (fromDate && !toDate) {
// If there is no end date, we set the end date to the end of the day
endDate = dayjs(newStartDate).endOf('hour')
} else if (!fromDate && toDate) {
// If there is no start date, we set the end date to the end of the day
endDate = dayjs(newStartDate).endOf('hour')
} else {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol.title!)
}
if (!newRow) {
return { newRow: null, updateProperty: [] }
}
// We use the primary key of the new row to find the old row in the formattedData array
// If the old row is found, we replace it with the new row in the formattedData array
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (dragElement.value) {
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
if (pk === newPk) {
return newRow
}
return r
})
} else {
// If the old row is not found, we add the new row to the formattedData array and remove the old row from the formattedSideBarData array
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
}
return { newRow, updateProperty }
}
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit')) return
if (!container.value || !resizeRecord.value) return
const { top, bottom } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
// If the mouse position is near the top or bottom of the scroll container, we scroll the container
if (event.clientY > bottom - 20) {
container.value.scrollTop += 10
} else if (event.clientY < top + 20) {
container.value.scrollTop -= 10
}
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
if (!fromCol || !toCol) return
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const hour = Math.max(Math.floor(percentY * 23), 0)
let newRow: Row | null = null
let updateProperty: string[] = []
if (resizeDirection.value === 'right') {
// If the user is resizing the record to the right, we calculate the new end date based on the mouse position
let newEndDate = dayjs(selectedDate.value).add(hour, 'hour')
updateProperty = [toCol.title!]
// If the new end date is before the start date, we set the new end date to the start date
// This is to ensure the end date is always same or after the start date
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) {
newEndDate = ogStartDate.clone()
}
if (!newEndDate.isValid()) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'),
},
}
} else if (resizeDirection.value === 'left') {
let newStartDate = dayjs(selectedDate.value).add(hour, 'hour')
updateProperty = [fromCol.title!]
// If the new start date is after the end date, we set the new start date to the end date
// This is to ensure the start date is always before or same the end date
if (dayjs(newStartDate).isAfter(ogEndDate)) {
newStartDate = dayjs(dayjs(ogEndDate)).clone()
}
if (!newStartDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
if (!newRow) return
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
}) as Row[]
useDebouncedRowUpdate(newRow, updateProperty, false)
}
const onResizeEnd = () => {
resizeDirection.value = null
resizeRecord.value = null
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
}
const onResizeStart = (direction: 'right' | 'left', _event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
resizeDirection.value = direction
resizeRecord.value = record
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', onResizeEnd)
}
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !dragRecord.value) return
const { top, bottom } = container.value.getBoundingClientRect()
if (event.clientY > bottom - 20) {
container.value.scrollTop += 10
} else if (event.clientY < top + 20) {
container.value.scrollTop -= 10
}
calculateNewRow(event)
}
const stopDrag = (event: MouseEvent) => {
event.preventDefault()
clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return
const { newRow, updateProperty } = calculateNewRow(event)
if (!newRow && !updateProperty) return
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
if (!newRow) return
updateRowProperty(newRow, updateProperty, false)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
let target = event.target as HTMLElement
isDragging.value = false
// We use a timeout to determine if the user is dragging or clicking on the record
dragTimeout.value = setTimeout(() => {
isDragging.value = true
while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement
}
// When the user starts dragging a record, we reduce opacity of all other records
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
// el.style.visibility = 'hidden'
el.style.opacity = '30%'
}
})
dragRecord.value = record
dragElement.value = target
dragRecord.value = record
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}, 200)
const onMouseUp = () => {
clearTimeout(dragTimeout.value!)
document.removeEventListener('mouseup', onMouseUp)
if (!isDragging.value) {
emit('expandRecord', record)
}
}
document.addEventListener('mouseup', onMouseUp)
}
const viewMore = (hour: dayjs.Dayjs) => {
sideBarFilterOption.value = 'selectedHours'
selectedTime.value = hour
showSideMenu.value = true
}
</script>
<template>
<div
v-if="recordsAcrossAllRange.record.length"
ref="container"
class="w-full relative no-selection h-[calc(100vh-10rem)] overflow-y-auto nc-scrollbar-md"
data-testid="nc-calendar-day-view"
>
<div
v-for="(hour, index) in hours"
:key="index"
:class="{
'!border-brand-500': hour.isSame(selectedTime),
}"
class="flex w-full min-h-20 relative border-1 group hover:bg-gray-50 border-white border-b-gray-100"
data-testid="nc-calendar-day-hour"
@click="selectedTime = hour"
>
<div class="pt-2 px-4 text-xs text-gray-500 font-semibold h-20">
{{ dayjs(hour).format('H A') }}
</div>
<div></div>
<NcDropdown
v-if="calendarRange.length > 1"
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
}"
auto-close
>
<NcButton
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<template #overlay>
<NcMenu class="w-64">
<NcMenuItem> Select date field to add </NcMenuItem>
<NcMenuItem
v-for="(range, calIndex) in calendarRange"
:key="calIndex"
class="text-gray-800 font-semibold text-sm"
@click="
() => {
let record = {
row: {
[range.fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'),
},
}
if (range.fk_to_col) {
record = {
row: {
...record.row,
[range.fk_to_col!.title!]: hour.add(1, 'hour').format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
emit('new-record', record)
}
"
>
<div class="flex items-center gap-1">
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" />
<span class="ml-1">{{ range.fk_from_col!.title! }}</span>
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-else
:class="{
'!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime),
}"
class="!group-hover:block mr-4 my-auto ml-auto z-10 top-0 bottom-0 !group-hover:block absolute"
size="xsmall"
type="secondary"
@click="
() => {
let record = {
row: {
[calendarRange[0].fk_from_col!.title!]: hour.format('YYYY-MM-DD HH:mm:ssZ'),
},
}
if (calendarRange[0].fk_to_col) {
record = {
row: {
...record.row,
[calendarRange[0].fk_to_col!.title!]: hour.add(1, 'hour').format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
emit('new-record', record)
}
"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<NcButton
v-if="recordsAcrossAllRange?.count?.[hour.format('HH:mm')]?.overflow"
class="!absolute bottom-2 text-center w-15 mx-auto inset-x-0 z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(hour)"
>
<span class="text-xs">
+
{{ recordsAcrossAllRange?.count[hour.format('HH:mm')]?.overflowCount }}
more
</span>
</NcButton>
</div>
<div class="absolute inset-0 pointer-events-none">
<div class="relative !ml-[60px]" data-testid="nc-calendar-day-record-container">
<div
v-for="(record, rowIndex) in recordsAcrossAllRange.record"
:key="rowIndex"
:data-testid="`nc-calendar-day-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="record.rowMeta.style"
class="absolute draggable-record group cursor-pointer pointer-events-auto"
@mousedown="dragStart($event, record)"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id as string"
@dragover.prevent
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta!.position"
:record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
color="blue"
@resize-start="onResizeStart"
>
<template v-if="!isRowEmpty(record, displayField)">
<div
:class="{
'!mt-2': displayField!.uidt === UITypes.SingleLineText,
'!mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField!)"
v-model="record.row[displayField!.title!]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField!.title!]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow>
</div>
</div>
</div>
</div>
<div v-else class="w-full h-full flex text-md font-bold text-gray-500 items-center justify-center">No records in this day</div>
</template>
<style lang="scss" scoped>
.no-selection {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

815
packages/nc-gui/components/smartsheet/calendar/MonthView.vue

@ -0,0 +1,815 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Row } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emit = defineEmits(['new-record', 'expandRecord'])
const {
selectedDate,
selectedMonth,
formattedData,
formattedSideBarData,
sideBarFilterOption,
displayField,
calendarRange,
showSideMenu,
updateRowProperty,
} = useCalendarViewStoreOrThrow()
const isMondayFirst = ref(true)
const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref())
const days = computed(() => {
if (isMondayFirst.value) {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
} else {
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
}
})
const calendarGridContainer = ref()
const { width: gridContainerWidth, height: gridContainerHeight } = useElementSize(calendarGridContainer)
const isDayInPagedMonth = (date: dayjs.Dayjs) => {
return date.month() === selectedMonth.value.month()
}
const dragElement = ref<HTMLElement | null>(null)
const draggingId = ref<string | null>(null)
const resizeInProgress = ref(false)
const isDragging = ref(false)
const dragRecord = ref<Row>()
const hoverRecord = ref<string | null>()
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const focusedDate = ref<dayjs.Dayjs | null>(null)
const resizeDirection = ref<'right' | 'left'>()
const resizeRecord = ref<Row>()
const dates = computed(() => {
const startOfMonth = selectedMonth.value.startOf('month')
const endOfMonth = selectedMonth.value.endOf('month')
const firstDayToDisplay = startOfMonth.startOf('week').add(isMondayFirst.value ? 0 : -1, 'day')
const lastDayToDisplay = endOfMonth.endOf('week').add(isMondayFirst.value ? 0 : -1, 'day')
const daysToDisplay = lastDayToDisplay.diff(firstDayToDisplay, 'day') + 1
let numberOfRows = Math.ceil(daysToDisplay / 7)
numberOfRows = Math.max(numberOfRows, 5)
const weeksArray: Array<Array<dayjs.Dayjs>> = []
let currentDay = firstDayToDisplay
for (let week = 0; week < numberOfRows; week++) {
const weekArray = []
for (let day = 0; day < 7; day++) {
weekArray.push(currentDay)
currentDay = currentDay.add(1, 'day')
}
weeksArray.push(weekArray)
}
return weeksArray
})
const recordsToDisplay = computed<{
records: Row[]
count: { [p: string]: { overflow: boolean; count: number; overflowCount: number } }
}>(() => {
if (!dates.value || !calendarRange.value) return []
const perWidth = gridContainerWidth.value / 7
const perHeight = gridContainerHeight.value / dates.value.length
const perRecordHeight = 40
const spaceBetweenRecords = 35
// This object is used to keep track of the number of records in a day
// The key is the date in the format YYYY-MM-DD
const recordsInDay: {
[key: string]: {
overflow: boolean
count: number
overflowCount: number
}
} = {}
if (!calendarRange.value) return []
const recordsToDisplay: Array<Row> = []
calendarRange.value.forEach((range) => {
const startCol = range.fk_from_col
const endCol = range.fk_to_col
// Filter out records that don't satisfy the range and sort them by start date
const sortedFormattedData = [...formattedData.value].filter((record) => {
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null
if (startCol && endCol) {
const fromDate = record.row[startCol!.title!] ? dayjs(record.row[startCol!.title!]) : null
const toDate = record.row[endCol!.title!] ? dayjs(record.row[endCol!.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (startCol && !endCol) {
return !!fromDate
}
return false
})
sortedFormattedData.forEach((record: Row) => {
if (!endCol && startCol) {
// If there is no end date, we just display the record on the start date
const startDate = dayjs(record.row[startCol!.title!])
const dateKey = startDate.format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 }
}
recordsInDay[dateKey].count++
const id = record.rowMeta.id ?? generateRandomNumber()
// Find the index of the week from the dates array
const weekIndex = dates.value.findIndex((week) => week.some((day) => dayjs(day).isSame(startDate, 'day')))
// Find the index of the day from the dates array
const dayIndex = (dates.value[weekIndex] ?? []).findIndex((day) => {
return dayjs(day).isSame(startDate, 'day')
})
const style: Partial<CSSStyleDeclaration> = {
left: `${dayIndex * perWidth}px`,
width: `${perWidth}px`,
}
// Number of records in that day
const recordIndex = recordsInDay[dateKey].count
// The top is calculated from the week index and the record index
// If the record in 1st week and no record in that date them the top will be 0
const top = weekIndex * perHeight + spaceBetweenRecords + (recordIndex - 1) * perRecordHeight
// The 25 is obtained from the trial and error
const heightRequired = perRecordHeight * recordIndex + spaceBetweenRecords + 25
if (heightRequired > perHeight) {
style.display = 'none'
recordsInDay[dateKey].overflow = true
recordsInDay[dateKey].overflowCount++
} else {
style.top = `${top}px`
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
style,
position: 'rounded',
range,
id,
},
})
} else if (startCol && endCol) {
// If the range specifies fromCol and endCol
const startDate = dayjs(record.row[startCol!.title!])
const endDate = dayjs(record.row[endCol!.title!])
let currentWeekStart = startDate.startOf('week')
const id = record.rowMeta.id ?? generateRandomNumber()
// Since the records can span multiple weeks, to display, we render multiple records
// for each week the record spans. The id is used to identify the elements that belong to the same record
while (
currentWeekStart.isSameOrBefore(endDate, 'day') &&
// If the current week start is before the last day of the last week
currentWeekStart.isBefore(dates.value[dates.value.length - 1][6])
) {
// We update the record start to currentWeekStart if it is before the start date
// and record end to currentWeekEnd if it is after the end date
const currentWeekEnd = currentWeekStart.endOf('week')
const recordStart = currentWeekStart.isBefore(startDate) ? startDate : currentWeekStart
const recordEnd = currentWeekEnd.isAfter(endDate) ? endDate : currentWeekEnd
// Update the recordsInDay object to keep track of the number of records in a day
let day = recordStart.clone()
while (day.isSameOrBefore(recordEnd)) {
const dateKey = day.format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = { overflow: false, count: 0, overflowCount: 0 }
}
recordsInDay[dateKey].count++
day = day.add(1, 'day')
}
// Find the index of the week from the dates array
const weekIndex = Math.max(
dates.value.findIndex((week) => {
return (
week.findIndex((day) => {
return dayjs(day).isSame(recordStart, 'day')
}) !== -1
)
}),
0,
)
let maxRecordCount = 0
// Find the maximum number of records in a day in that week
for (let i = 0; i < (dates.value[weekIndex] ?? []).length; i++) {
const day = dates.value[weekIndex][i]
const dateKey = dayjs(day).format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) {
recordsInDay[dateKey] = {
count: 0,
overflow: false,
overflowCount: 0,
}
}
const recordIndex = recordsInDay[dateKey].count
maxRecordCount = Math.max(maxRecordCount, recordIndex)
}
const startDayIndex = Math.max(
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordStart, 'day')),
0,
)
const endDayIndex = Math.max(
(dates.value[weekIndex] ?? []).findIndex((day) => dayjs(day).isSame(recordEnd, 'day')),
0,
)
// The left and width of the record is calculated based on the start and end day index
const style: Partial<CSSStyleDeclaration> = {
left: `${startDayIndex * perWidth}px`,
width: `${(endDayIndex - startDayIndex + 1) * perWidth}px`,
}
// The top is calculated from the week index and the record index
// If the record in 1st week and no record in that date them the top will be 0
// If the record in 1st week and 1 record in that date then the top will be perRecordHeight + spaceBetweenRecords
const top = weekIndex * perHeight + spaceBetweenRecords + Math.max(maxRecordCount - 1, 0) * perRecordHeight
const heightRequired = perRecordHeight * maxRecordCount + spaceBetweenRecords
let position = 'rounded'
// Here we are checking if the startDay is before all the dates shown in UI rather that the current month
const isStartMonthBeforeCurrentWeek = dates.value[weekIndex - 1]
? dayjs(dates.value[weekIndex - 1][0]).isBefore(startDate, 'month')
: false
if (startDate.isSame(currentWeekStart, 'week') && endDate.isSame(currentWeekEnd, 'week')) {
position = 'rounded'
} else if (startDate.isSame(recordStart, 'week')) {
if (isStartMonthBeforeCurrentWeek) {
if (endDate.isSame(currentWeekEnd, 'week')) {
position = 'rounded'
} else position = 'leftRounded'
} else position = 'leftRounded'
} else if (endDate.isSame(currentWeekEnd, 'week')) {
position = 'rightRounded'
} else {
position = 'none'
}
// If the height required is more than the height of the week, we hide the record
// and update the recordsInDay object for all the spanned days
if (heightRequired + 15 > perHeight) {
style.display = 'none'
for (let i = startDayIndex; i <= endDayIndex; i++) {
const week = dates.value[weekIndex]
if (!week) continue
const day = week[i]
const dateKey = dayjs(day).format('YYYY-MM-DD')
if (!recordsInDay[dateKey]) continue
recordsInDay[dateKey].overflow = true
recordsInDay[dateKey].overflowCount++
}
} else {
style.top = `${top}px`
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
position,
style,
range,
id,
},
})
currentWeekStart = currentWeekStart.add(1, 'week')
}
}
})
})
return {
records: recordsToDisplay,
count: recordsInDay,
}
})
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height
const percentX = (event.clientX - left - window.scrollX) / width
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
const week = Math.floor(percentY * dates.value.length)
const day = Math.floor(percentX * 7)
const newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null
if (!newStartDate) return
let endDate
const newRow = {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
const updateProperty = [fromCol!.title!]
if (toCol) {
const fromDate = dragRecord.value.row[fromCol!.title!] ? dayjs(dragRecord.value.row[fromCol!.title!]) : null
const toDate = dragRecord.value.row[toCol!.title!] ? dayjs(dragRecord.value.row[toCol!.title!]) : null
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else {
endDate = newStartDate.clone()
}
dragRecord.value = undefined
newRow.row[toCol!.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol!.title!)
}
if (!newRow) return
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (updateSideBar) {
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
} else {
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
}
return {
newRow,
updateProperty,
}
}
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !dragRecord.value) return
calculateNewRow(event, false)
}
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !resizeRecord.value) return
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height
const percentX = (event.clientX - left - window.scrollX) / width
const ogEndDate = resizeRecord.value.row[resizeRecord.value.rowMeta!.range!.fk_to_col!.title!]
const ogStartDate = resizeRecord.value.row[resizeRecord.value.rowMeta!.range!.fk_from_col!.title!]
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
const week = Math.floor(percentY * dates.value.length)
const day = Math.floor(percentX * 7)
let updateProperty: string[] = []
let newRow: Row
if (resizeDirection.value === 'right') {
let newEndDate = dates.value[week] ? dayjs(dates.value[week][day]).endOf('day') : null
updateProperty = [toCol!.title!]
if (dayjs(newEndDate).isBefore(ogStartDate)) {
newEndDate = dayjs(ogStartDate).clone().endOf('day')
}
if (!newEndDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol!.title!]: dayjs(newEndDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
} else if (resizeDirection.value === 'left') {
let newStartDate = dates.value[week] ? dayjs(dates.value[week][day]) : null
updateProperty = [fromCol!.title!]
if (dayjs(newStartDate).isAfter(ogEndDate)) {
newStartDate = dayjs(ogEndDate).clone()
}
if (!newStartDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
useDebouncedRowUpdate(newRow, updateProperty, false)
}
const onResizeEnd = () => {
resizeInProgress.value = false
resizeDirection.value = undefined
resizeRecord.value = undefined
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
}
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit') || draggingId.value) return
selectedDate.value = null
resizeInProgress.value = true
resizeDirection.value = direction
resizeRecord.value = record
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', onResizeEnd)
}
const stopDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !dragRecord.value || !isDragging.value) return
event.preventDefault()
clearTimeout(dragTimeout.value)
dragElement.value!.style.boxShadow = 'none'
const { newRow, updateProperty } = calculateNewRow(event, false)
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
isDragging.value = false
draggingId.value = null
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
focusedDate.value = null
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit') || resizeInProgress.value || !record.rowMeta.id) return
let target = event.target as HTMLElement
isDragging.value = false
dragTimeout.value = setTimeout(() => {
isDragging.value = true
while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
// el.style.visibility = 'hidden'
el.style.opacity = '30%'
}
})
dragRecord.value = record
selectedDate.value = null
isDragging.value = true
dragElement.value = target
draggingId.value = record.rowMeta!.id!
dragRecord.value = record
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}, 200)
const onMouseUp = () => {
clearTimeout(dragTimeout.value)
document.removeEventListener('mouseup', onMouseUp)
if (!isDragging.value) {
emit('expandRecord', record)
}
}
document.addEventListener('mouseup', onMouseUp)
}
const dropEvent = (event: DragEvent) => {
if (!isUIAllowed('dataEdit')) return
event.preventDefault()
const data = event.dataTransfer?.getData('text/plain')
if (data) {
const {
record,
}: {
record: Row
} = JSON.parse(data)
dragRecord.value = record
const { newRow, updateProperty } = calculateNewRow(event, true)
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
}
}
const selectDate = (date: dayjs.Dayjs) => {
dragRecord.value = undefined
draggingId.value = null
resizeRecord.value = undefined
resizeInProgress.value = false
resizeDirection.value = undefined
focusedDate.value = null
selectedDate.value = date
}
const viewMore = (date: dayjs.Dayjs) => {
sideBarFilterOption.value = 'selectedDate' as const
selectedDate.value = date
showSideMenu.value = true
}
const isDateSelected = (date: dayjs.Dayjs) => {
if (!selectedDate.value) return false
return dayjs(date).isSame(selectedDate.value, 'day')
}
</script>
<template>
<div v-if="calendarRange" class="h-full prevent-select relative" data-testid="nc-calendar-month-view">
<div class="grid grid-cols-7">
<div
v-for="(day, index) in days"
:key="index"
class="text-center bg-gray-50 py-1 text-sm border-b-1 border-r-1 last:border-r-0 border-gray-200 font-semibold text-gray-500"
>
{{ day }}
</div>
</div>
<div
ref="calendarGridContainer"
:class="{
'grid-rows-5': dates.length === 5,
'grid-rows-6': dates.length === 6,
'grid-rows-7': dates.length === 7,
}"
class="grid h-full pb-7.5"
@drop="dropEvent"
>
<div v-for="(week, weekIndex) in dates" :key="weekIndex" class="grid grid-cols-7 grow" data-testid="nc-calendar-month-week">
<div
v-for="(day, dateIndex) in week"
:key="`${weekIndex}-${dateIndex}`"
:class="{
'border-brand-500 border-1 !border-r-1 border-b-1':
isDateSelected(day) || (focusedDate && dayjs(day).isSame(focusedDate, 'day')),
'!text-gray-400': !isDayInPagedMonth(day),
}"
class="text-right relative group last:border-r-0 text-sm h-full border-r-1 border-b-1 border-gray-200 font-medium hover:bg-gray-50 text-gray-800 bg-white"
data-testid="nc-calendar-month-day"
@click="selectDate(day)"
>
<div v-if="isUIAllowed('dataEdit')" class="flex justify-between p-1">
<span
:class="{
block: !isDateSelected(day),
hidden: isDateSelected(day),
}"
class="group-hover:hidden"
></span>
<NcDropdown v-if="calendarRange.length > 1" auto-close>
<NcButton
:class="{
'!block': isDateSelected(day),
'!hidden': !isDateSelected(day),
}"
class="!group-hover:block"
size="small"
type="secondary"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<template #overlay>
<NcMenu class="w-64">
<NcMenuItem> Select date field to add </NcMenuItem>
<NcMenuItem
v-for="(range, index) in calendarRange"
:key="index"
class="text-gray-800 font-semibold text-sm"
@click="
() => {
const record = {
row: {
[range.fk_from_col!.title!]: dayjs(day).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
emit('new-record', record)
}
"
>
<div class="flex items-center gap-1">
<LazySmartsheetHeaderCellIcon :column-meta="range.fk_from_col" />
<span class="ml-1">{{ range.fk_from_col!.title }}</span>
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<NcButton
:class="{
'!block': isDateSelected(day),
'!hidden': !isDateSelected(day),
}"
class="!group-hover:block"
size="small"
type="secondary"
@click="
() => {
const record = {
row: {
[calendarRange[0].fk_from_col!.title!]: (day).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
emit('new-record', record)
}
"
>
<component :is="iconMap.plus" class="h-4 w-4" />
</NcButton>
<span
:class="{
'bg-brand-50 text-brand-500': day.isSame(dayjs(), 'date'),
}"
class="px-1.5 rounded-lg py-1 my-1"
>
{{ day.format('DD') }}
</span>
</div>
<div v-if="!isUIAllowed('dataEdit')" class="p-3">{{ dayjs(day).format('DD') }}</div>
<NcButton
v-if="
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')] &&
recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflow &&
!draggingId
"
class="!absolute bottom-1 text-center w-15 mx-auto inset-x-0 z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(day)"
>
<span class="text-xs"> + {{ recordsToDisplay.count[dayjs(day).format('YYYY-MM-DD')]?.overflowCount }} more </span>
</NcButton>
</div>
</div>
</div>
<div class="absolute inset-0 pointer-events-none mt-8 pb-7.5" data-testid="nc-calendar-month-record-container">
<div
v-for="(record, recordIndex) in recordsToDisplay.records"
:key="recordIndex"
:data-testid="`nc-calendar-month-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="{
...record.rowMeta.style,
zIndex: record.rowMeta.id === draggingId ? 100 : 0,
boxShadow:
record.rowMeta.id === draggingId
? '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)'
: 'none',
}"
class="absolute group draggable-record cursor-pointer pointer-events-auto"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id"
@mousedown.stop="dragStart($event, record)"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta.position"
:record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
:selected="
dragRecord
? dragRecord.rowMeta.id === record.rowMeta.id
: resizeRecord
? resizeRecord.rowMeta.id === record.rowMeta.id
: false
"
@resize-start="onResizeStart"
@dblclick.stop="emit('expand-record', record)"
>
<template v-if="!isRowEmpty(record, displayField)">
<div
:class="{
'mt-1.4': displayField!.uidt === UITypes.SingleLineText,
'mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField!)"
v-model="record.row[displayField!.title!]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField!.title!]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.hide {
transition: 0.01s;
transform: translateX(-9999px);
}
.prevent-select {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
</style>

101
packages/nc-gui/components/smartsheet/calendar/RecordCard.vue

@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { Row } from '~/lib'
interface Props {
color?: string
resize?: boolean
hover?: boolean
record?: Row
selected?: boolean
size?: 'small' | 'medium' | 'large' | 'auto'
position?: 'leftRounded' | 'rightRounded' | 'rounded' | 'none'
}
withDefaults(defineProps<Props>(), {
resize: true,
selected: false,
hover: false,
color: 'blue',
size: 'small',
position: 'rounded',
})
const emit = defineEmits(['resize-start'])
</script>
<template>
<div
:class="{
'min-h-9': size === 'small',
'h-full': size === 'auto',
'rounded-l-lg ml-1': position === 'leftRounded',
'rounded-r-lg mr-1': position === 'rightRounded',
'rounded-lg mx-1': position === 'rounded',
'rounded-none': position === 'none',
'bg-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500 border-1)': resize,
'!border-brand-500 border-1': selected || hover,
}"
class="relative transition-all flex items-center px-1 group border-1 border-transparent"
>
<div
v-if="position === 'leftRounded' || position === 'rounded'"
:class="{
'bg-maroon-500': color === 'maroon',
'bg-blue-500': color === 'blue',
'bg-green-500': color === 'green',
'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple',
}"
class="block h-full min-h-5 w-1 rounded"
></div>
<div v-if="(position === 'leftRounded' || position === 'rounded') && resize" class="mt-0.1 h-7.1 absolute -left-4 resize">
<NcButton
:class="{
'!block z-1 !border-brand-500': selected || hover,
'!hidden': !selected && !hover,
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
<div class="overflow-hidden ml-2 h-8 absolute">
<span v-if="position === 'rightRounded' || position === 'none'" class="mr-1"> .... </span>
<span class="text-sm !w-[80%] text-gray-800">
<slot />
</span>
<span v-if="position === 'leftRounded' || position === 'none'" class="absolute my-0 right-5"> .... </span>
</div>
<div v-if="(position === 'rightRounded' || position === 'rounded') && resize" class="absolute mt-0.1 z-1 -right-4 resize">
<NcButton
:class="{
'!block !border-brand-500': selected || hover,
'!hidden': !selected && !hover,
}"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
</div>
</template>
<style lang="scss" scoped>
.resize {
cursor: ew-resize;
}
</style>

456
packages/nc-gui/components/smartsheet/calendar/SideMenu.vue

@ -0,0 +1,456 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import dayjs from 'dayjs'
import { type Row, computed, iconMap, isRowEmpty, ref } from '#imports'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits(['expand-record', 'newRecord'])
const INFINITY_SCROLL_THRESHOLD = 100
const { isUIAllowed } = useRoles()
const { appInfo } = useGlobal()
const meta = inject(MetaInj, ref())
const { t } = useI18n()
const {
pageDate,
displayField,
selectedDate,
selectedTime,
selectedMonth,
calendarRange,
selectedDateRange,
activeDates,
activeCalendarView,
isSidebarLoading,
formattedSideBarData,
calDataType,
loadMoreSidebarData,
searchQuery,
sideBarFilterOption,
} = useCalendarViewStoreOrThrow()
const sideBarListRef = ref<VNodeRef | null>(null)
const pushToArray = (arr: Array<Row>, record: Row, range) => {
arr.push({
...record,
rowMeta: {
...record.rowMeta,
range,
},
})
}
const dragElement = ref<HTMLElement | null>(null)
const dragStart = (event: DragEvent, record: Row) => {
dragElement.value = event.target as HTMLElement
dragElement.value.style.boxShadow = '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)'
const eventRect = dragElement.value.getBoundingClientRect()
const initialClickOffsetX = event.clientX - eventRect.left
const initialClickOffsetY = event.clientY - eventRect.top
event.dataTransfer?.setData(
'text/plain',
JSON.stringify({
record,
initialClickOffsetY,
initialClickOffsetX,
}),
)
}
const renderData = computed<Array<Row>>(() => {
if (!calendarRange.value) return []
const rangedData: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
formattedSideBarData.value.forEach((record) => {
if (fromCol && toCol) {
const from = dayjs(record.row[fromCol.title!])
const to = dayjs(record.row[toCol.title!])
if (sideBarFilterOption.value === 'withoutDates') {
if (!from.isValid() || !to.isValid()) {
pushToArray(rangedData, record, range)
}
} else if (sideBarFilterOption.value === 'allRecords') {
pushToArray(rangedData, record, range)
} else if (
sideBarFilterOption.value === 'month' ||
sideBarFilterOption.value === 'year' ||
sideBarFilterOption.value === 'selectedDate' ||
sideBarFilterOption.value === 'selectedHours' ||
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === 'day'
) {
let fromDate: dayjs.Dayjs | null = null
let toDate: dayjs.Dayjs | null = null
switch (sideBarFilterOption.value) {
case 'month':
fromDate = dayjs(selectedMonth.value).startOf('month')
toDate = dayjs(selectedMonth.value).endOf('month')
break
case 'year':
fromDate = dayjs(selectedDate.value).startOf('year')
toDate = dayjs(selectedDate.value).endOf('year')
break
case 'selectedDate':
fromDate = dayjs(selectedDate.value).startOf('day')
toDate = dayjs(selectedDate.value).endOf('day')
break
case 'week':
fromDate = dayjs(selectedDateRange.value.start).startOf('week')
toDate = dayjs(selectedDateRange.value.end).endOf('week')
break
case 'day':
fromDate = dayjs(selectedDate.value).startOf('day')
toDate = dayjs(selectedDate.value).endOf('day')
break
case 'selectedHours':
fromDate = dayjs(selectedTime.value).startOf('hour')
toDate = dayjs(selectedTime.value).endOf('hour')
break
}
if (from && to) {
if (
(from.isSameOrAfter(fromDate) && to.isSameOrBefore(toDate)) ||
(from.isSameOrBefore(fromDate) && to.isSameOrAfter(toDate)) ||
(from.isSameOrBefore(fromDate) && to.isSameOrAfter(fromDate)) ||
(from.isSameOrBefore(toDate) && to.isSameOrAfter(toDate))
) {
pushToArray(rangedData, record, range)
}
}
}
} else if (fromCol) {
const from = dayjs(record.row[fromCol.title!])
if (sideBarFilterOption.value === 'withoutDates') {
if (!from.isValid()) {
pushToArray(rangedData, record, range)
}
} else if (sideBarFilterOption.value === 'allRecords') {
pushToArray(rangedData, record, range)
} else if (sideBarFilterOption.value === 'selectedDate' || sideBarFilterOption.value === 'day') {
if (from.isSame(selectedDate.value, 'day')) {
pushToArray(rangedData, record, range)
}
} else if (sideBarFilterOption.value === 'selectedHours') {
if (from.isSame(selectedTime.value, 'hour')) {
pushToArray(rangedData, record, range)
}
} else if (
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === 'month' ||
sideBarFilterOption.value === 'year'
) {
let fromDate: dayjs.Dayjs
let toDate: dayjs.Dayjs
switch (sideBarFilterOption.value) {
case 'week':
fromDate = dayjs(selectedDateRange.value.start).startOf('week')
toDate = dayjs(selectedDateRange.value.end).endOf('week')
break
case 'month':
fromDate = dayjs(selectedMonth.value).startOf('month')
toDate = dayjs(selectedMonth.value).endOf('month')
break
case 'year':
fromDate = dayjs(selectedDate.value).startOf('year')
toDate = dayjs(selectedDate.value).endOf('year')
break
}
if (from.isSameOrAfter(fromDate) && from.isSameOrBefore(toDate)) {
pushToArray(rangedData, record, range)
}
}
}
})
})
return rangedData
})
const options = computed(() => {
switch (activeCalendarView.value) {
case 'day' as const:
if (calDataType.value === UITypes.Date) {
return [
{ label: 'In this day', value: 'day' },
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'All records', value: 'allRecords' },
]
} else {
return [
{ label: 'In this day', value: 'day' },
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected hours', value: 'selectedHours' },
]
}
case 'week' as const:
if (calDataType.value === UITypes.Date) {
return [
{ label: 'In selected date', value: 'selectedDate' },
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'In selected week', value: 'week' },
{ label: 'All records', value: 'allRecords' },
]
} else {
return [
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected hours', value: 'selectedHours' },
{ label: 'In selected week', value: 'week' },
{ label: 'In selected date', value: 'selectedDate' },
]
}
case 'month' as const:
return [
{ label: 'In this month', value: 'month' },
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected date', value: 'selectedDate' },
]
case 'year' as const:
return [
{ label: 'In this year', value: 'year' },
{ label: 'Without dates', value: 'withoutDates' },
{ label: 'All records', value: 'allRecords' },
{ label: 'In selected date', value: 'selectedDate' },
]
}
})
const sideBarListScrollHandle = useDebounceFn(async (e: Event) => {
const target = e.target as HTMLElement
if (target.clientHeight + target.scrollTop + INFINITY_SCROLL_THRESHOLD >= target.scrollHeight) {
const pageSize = appInfo.value?.defaultLimit ?? 25
const page = Math.ceil(formattedSideBarData.value.length / pageSize)
await loadMoreSidebarData({
offset: page * pageSize,
})
}
})
const newRecord = () => {
const row = {
...rowDefaultData(meta.value?.columns),
}
if (activeCalendarView.value === 'day') {
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ')
} else if (activeCalendarView.value === 'week') {
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDateRange.value.start.format('YYYY-MM-DD HH:mm:ssZ')
} else if (activeCalendarView.value === 'month') {
row[calendarRange.value[0]!.fk_from_col!.title!] = (selectedDate.value ?? selectedMonth.value).format('YYYY-MM-DD HH:mm:ssZ')
} else if (activeCalendarView.value === 'year') {
row[calendarRange.value[0]!.fk_from_col!.title!] = selectedDate.value.format('YYYY-MM-DD HH:mm:ssZ')
}
emit('newRecord', { row, oldRow: {}, rowMeta: { new: true } })
}
const height = ref(0)
const heightListener = () => {
height.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', heightListener)
})
onUnmounted(() => {
window.removeEventListener('resize', heightListener)
})
</script>
<template>
<div
:class="{
'w-0': !props.visible,
'w-1/6 min-w-[22.1rem] nc-calendar-side-menu-open': props.visible,
}"
class="h-full border-l-1 border-gray-200 transition-all"
data-testid="nc-calendar-side-menu"
>
<div
:class="{
'!hidden': height < 918,
}"
class="flex flex-col"
>
<NcDateWeekSelector
v-if="activeCalendarView === ('day' as const)"
v-model:active-dates="activeDates"
v-model:page-date="pageDate"
v-model:selected-date="selectedDate"
/>
<NcDateWeekSelector
v-else-if="activeCalendarView === ('week' as const)"
v-model:active-dates="activeDates"
v-model:page-date="pageDate"
v-model:selected-week="selectedDateRange"
is-week-picker
/>
<NcMonthYearSelector
v-else-if="activeCalendarView === ('month' as const)"
v-model:page-date="pageDate"
v-model:selected-date="selectedMonth"
/>
<NcMonthYearSelector
v-else-if="activeCalendarView === ('year' as const)"
v-model:page-date="pageDate"
v-model:selected-date="selectedDate"
is-year-picker
/>
</div>
<div
:class="{
'!border-t-0': height < 918,
}"
class="px-4 border-t-1 border-gray-200 relative flex flex-col gap-y-4 pt-3"
>
<div class="flex items-center gap-2">
<a-input
v-model:value="searchQuery.value"
:class="{
'!border-brand-500': searchQuery.value.length > 0,
}"
class="!rounded-lg !h-8 !border-gray-200 !px-4"
data-testid="nc-calendar-sidebar-search"
placeholder="Search records"
>
<template #prefix>
<component :is="iconMap.search" class="h-4 w-4 mr-1 text-gray-500" />
</template>
</a-input>
<NcSelect v-model:value="sideBarFilterOption" class="min-w-38 !text-gray-800" data-testid="nc-calendar-sidebar-filter">
<a-select-option v-for="option in options" :key="option.value" :value="option.value" class="!text-gray-800">
<div class="flex items-center justify-between gap-2">
<div class="truncate flex-1">
<NcTooltip :title="option.label" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
<component
:is="iconMap.check"
v-if="sideBarFilterOption === option.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</div>
<div
v-if="calendarRange"
:ref="sideBarListRef"
:class="{
'!h-[calc(100vh-10.5rem)]': height < 918,
'h-[calc(100vh-36.2rem)]': activeCalendarView === ('day' as const) || activeCalendarView === ('week' as const) && height > 918,
'h-[calc(100vh-25.1rem)]': activeCalendarView === ('month' as const) || activeCalendarView === ('year' as const) && height > 918,
}"
class="gap-2 flex flex-col nc-scrollbar-md overflow-y-auto nc-calendar-top-height"
data-testid="nc-calendar-side-menu-list"
@scroll="sideBarListScrollHandle"
>
<NcButton
v-if="isUIAllowed('dataEdit') && props.visible"
v-e="['c:calendar:calendar-new-record-btn']"
class="!absolute right-5 !border-brand-500 bottom-5 !h-12 !w-12"
data-testid="nc-calendar-side-menu-new-btn"
type="secondary"
@click="newRecord"
>
<div class="px-4 flex items-center gap-2 justify-center">
<component :is="iconMap.plus" class="h-6 w-6 text-lg text-brand-500" />
</div>
</NcButton>
<div v-if="renderData.length === 0 || isSidebarLoading" class="flex h-full items-center justify-center">
<GeneralLoader v-if="isSidebarLoading" size="large" />
<div v-else class="text-gray-500">
{{ t('msg.noRecordsFound') }}
</div>
</div>
<template v-else-if="renderData.length > 0">
<LazySmartsheetRow v-for="(record, rowIndex) in renderData" :key="rowIndex" :row="record">
<LazySmartsheetCalendarSideRecordCard
:draggable="sideBarFilterOption === 'withoutDates' && activeCalendarView !== 'year'"
:from-date="
record.rowMeta.range?.fk_from_col
? calDataType === UITypes.Date
? dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM')
: dayjs(record.row[record.rowMeta.range.fk_from_col.title!]).format('DD MMM•HH:mm A')
: null
"
:invalid="
record.rowMeta.range!.fk_to_col &&
dayjs(record.row[record.rowMeta.range!.fk_from_col.title!]).isAfter(
dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]),
)
"
:row="record"
:to-date="
record.rowMeta.range!.fk_to_col
? calDataType === UITypes.Date
? dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM')
: dayjs(record.row[record.rowMeta.range!.fk_to_col.title!]).format('DD MMM•HH:mm A')
: null
"
color="blue"
data-testid="nc-sidebar-record-card"
@click="emit('expand-record', record)"
@dragstart="dragStart($event, record)"
@dragover.prevent
>
<template v-if="!isRowEmpty(record, displayField)">
<div :class="{}">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField)"
v-model="record.row[displayField.title]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField.title]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarSideRecordCard>
</LazySmartsheetRow>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

50
packages/nc-gui/components/smartsheet/calendar/SideRecordCard.vue

@ -0,0 +1,50 @@
<script lang="ts" setup>
interface Props {
fromDate?: string
toDate?: string
color?: string
showDate?: boolean
invalid?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fromDate: '',
color: 'blue',
showDate: true,
invalid: false,
})
</script>
<template>
<div class="border-1 cursor-pointer border-gray-200 items-center px-2 py-3 rounded-lg">
<div class="flex items-center gap-2">
<span
:class="{
'bg-maroon-500': props.color === 'maroon',
'bg-blue-500': props.color === 'blue',
'bg-green-500': props.color === 'green',
'bg-yellow-500': props.color === 'yellow',
'bg-pink-500': props.color === 'pink',
'bg-purple-500': props.color === 'purple',
}"
class="block h-10 w-1 rounded"
></span>
<div class="flex text-ellipsis gap-1 flex-col">
<span class="text-sm max-w-40 truncate text-gray-800">
<slot />
</span>
<span v-if="showDate" class="text-xs text-gray-500">{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span>
</div>
</div>
<div v-if="invalid" class="gap-3 bg-yellow-50 mt-2 border-gray-200 border-1 rounded-lg p-2 flex">
<component :is="iconMap.warning" class="text-yellow-500 h-4 w-4" />
<div class="flex flex-col gap-1">
<h1 class="text-gray-800 text-bold">Date mismatch</h1>
<p class="text-gray-500">Selected End date is before Start date.</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

97
packages/nc-gui/components/smartsheet/calendar/VRecordCard.vue

@ -0,0 +1,97 @@
<script lang="ts" setup>
interface Props {
record: Record<string, string>
color?: string
resize?: boolean
selected?: boolean
hover?: boolean
position?: 'topRounded' | 'bottomRounded' | 'rounded' | 'none'
}
withDefaults(defineProps<Props>(), {
resize: true,
selected: false,
hover: false,
color: 'blue',
position: 'rounded',
})
const emit = defineEmits(['resize-start'])
</script>
<template>
<div
v-if="(position === 'topRounded' || position === 'rounded') && resize"
class="absolute flex items-center justify-center w-full -mt-4 h-7.1 hidden z-1 resize !group-hover:(flex rounded-lg)"
>
<NcButton
:class="{
'!flex border-1 rounded-lg border-brand-500': selected || hover,
}"
class="!group-hover:(border-brand-500) !border-1 cursor-ns-resize"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'left', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
<div
:class="{
'rounded-t-lg': position === 'topRounded',
'rounded-b-lg': position === 'bottomRounded',
'rounded-lg': position === 'rounded',
'rounded-none': position === 'none',
'bg-maroon-50': color === 'maroon',
'bg-blue-50': color === 'blue',
'bg-green-50': color === 'green',
'bg-yellow-50': color === 'yellow',
'bg-pink-50': color === 'pink',
'bg-purple-50': color === 'purple',
'group-hover:(border-brand-500)': resize,
'!border-brand-500 border-1': selected || hover,
}"
class="relative h-full ml-0.25 border-1 border-gray-50"
>
<div class="h-full absolute py-2">
<div
:class="{
'bg-maroon-500': color === 'maroon',
'bg-blue-500': color === 'blue',
'bg-green-500': color === 'green',
'bg-yellow-500': color === 'yellow',
'bg-pink-500': color === 'pink',
'bg-purple-500': color === 'purple',
}"
class="block h-full min-h-5 ml-1 w-1 rounded mr-2"
></div>
</div>
<div v-if="position === 'bottomRounded' || position === 'none'" class="ml-3">....</div>
<div class="ml-3 pr-3 text-ellipsis overflow-hidden w-full h-8 absolute">
<span class="text-sm text-gray-800">
<slot />
</span>
</div>
<div v-if="position === 'topRounded' || position === 'none'" class="h-full pb-7 flex items-end ml-3">....</div>
</div>
<div
v-if="(position === 'bottomRounded' || position === 'rounded') && resize"
class="absolute items-center justify-center w-full hidden h-7.1 -mt-4 !group-hover:(flex rounded-lg)"
>
<NcButton
:class="{
'!flex border-1 rounded-lg z-1 cursor-ns-resize border-brand-500': selected || hover,
}"
class="!group-hover:(border-brand-500) !border-1"
size="xsmall"
type="secondary"
@mousedown.stop="emit('resize-start', 'right', $event, record)"
>
<component :is="iconMap.drag" class="text-gray-400"></component>
</NcButton>
</div>
</template>
<style lang="scss" scoped></style>

605
packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue

@ -0,0 +1,605 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { ref } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord'])
const { selectedDateRange, formattedData, formattedSideBarData, calendarRange, selectedDate, displayField, updateRowProperty } =
useCalendarViewStoreOrThrow()
const container = ref<null | HTMLElement>(null)
const { width: containerWidth } = useElementSize(container)
const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref())
// Calculate the dates of the week
const weekDates = computed(() => {
const startOfWeek = new Date(selectedDateRange.value.start!)
const endOfWeek = new Date(selectedDateRange.value.end!)
const datesArray = []
while (startOfWeek.getTime() <= endOfWeek.getTime()) {
datesArray.push(new Date(startOfWeek))
startOfWeek.setDate(startOfWeek.getDate() + 1)
}
return datesArray
})
// This function is used to find the first suitable row for a record
// It takes the recordsInDay object, the start day index and the span of the record in days
// It returns the first suitable row for the entire span of the record
const findFirstSuitableRow = (recordsInDay: any, startDayIndex: number, spanDays: number) => {
let row = 0
while (true) {
let isRowSuitable = true
// Check if the row is suitable for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDayIndex + i
if (!recordsInDay[dayIndex]) {
recordsInDay[dayIndex] = {}
}
// If the row is occupied, the entire span is not suitable
if (recordsInDay[dayIndex][row]) {
isRowSuitable = false
break
}
}
// If the row is suitable, return it
if (isRowSuitable) {
return row
}
row++
}
}
const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return []
// We use the recordsInDay object to keep track of which columns are occupied for each day
// This is used to calculate the position of the records in the calendar
// The key is the day index (0-6) and the value is an object with the row index as the key and a boolean as the value
// Since no hours are considered, the rowIndex will be sufficient to calculate the position
const recordsInDay: {
[key: number]: {
[key: number]: boolean
}
} = {
0: {},
1: {},
2: {},
3: {},
4: {},
5: {},
6: {},
}
const recordsInRange: Array<Row> = []
const perDayWidth = containerWidth.value / 7
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
if (fromCol && toCol) {
// Filter out records that have an invalid date range
// i.e. the end date is before the start date
for (const record of [...formattedData.value].filter((r) => {
const startDate = dayjs(r.row[fromCol.title!])
const endDate = dayjs(r.row[toCol.title!])
if (!startDate.isValid() || !endDate.isValid()) return false
return !endDate.isBefore(startDate)
})) {
// Generate a unique id for the record if it doesn't have one
const id = record.row.id ?? generateRandomNumber()
let startDate = dayjs(record.row[fromCol.title!])
const ogStartDate = startDate.clone()
const endDate = dayjs(record.row[toCol.title!])
// If the start date is before the selected date range, we need to adjust the start date
if (startDate.isBefore(selectedDateRange.value.start)) {
startDate = dayjs(selectedDateRange.value.start)
}
const startDaysDiff = startDate.diff(selectedDateRange.value.start, 'day')
// Calculate the span of the record in days
let spanDays = Math.max(Math.min(endDate.diff(startDate, 'day'), 6) + 1, 1)
// If the end date is after the month of the selected date range, we need to adjust the span
if (endDate.isAfter(startDate, 'month')) {
spanDays = 7 - startDaysDiff
}
if (startDaysDiff > 0) {
spanDays = Math.min(spanDays, 7 - startDaysDiff)
}
const widthStyle = `calc(max(${spanDays} * ${perDayWidth}px, ${perDayWidth}px))`
let suitableRow = -1
// Find the first suitable row for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDaysDiff + i
if (!recordsInDay[dayIndex]) {
recordsInDay[dayIndex] = {}
}
if (suitableRow === -1) {
suitableRow = findFirstSuitableRow(recordsInDay, dayIndex, spanDays)
}
}
// Mark the suitable column as occupied for the entire span
for (let i = 0; i < spanDays; i++) {
const dayIndex = startDaysDiff + i
recordsInDay[dayIndex][suitableRow] = true
}
let position = 'none'
const isStartInRange =
ogStartDate && ogStartDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
const isEndInRange = endDate && endDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
// Calculate the position of the record in the calendar based on the start and end date
// The position can be 'none', 'leftRounded', 'rightRounded', 'rounded'
// This is used to assign the rounded corners to the records
if (isStartInRange && isEndInRange) {
position = 'rounded'
} else if (
startDate &&
endDate &&
startDate.isBefore(selectedDateRange.value.start) &&
endDate.isAfter(selectedDateRange.value.end)
) {
position = 'none'
} else if (
startDate &&
endDate &&
(startDate.isAfter(selectedDateRange.value.end) || endDate.isBefore(selectedDateRange.value.start))
) {
position = 'none'
} else if (isStartInRange) {
position = 'leftRounded'
} else if (isEndInRange) {
position = 'rightRounded'
}
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
position,
id,
style: {
width: widthStyle,
left: `${startDaysDiff * perDayWidth}px`,
top: `${suitableRow * 40}px`,
},
},
})
}
} else if (fromCol) {
for (const record of formattedData.value) {
const id = record.row.id ?? generateRandomNumber()
const startDate = dayjs(record.row[fromCol.title!])
const startDaysDiff = Math.max(startDate.diff(selectedDateRange.value.start, 'day'), 0)
// Find the first suitable row for record. Here since the span is 1, we can use the findFirstSuitableRow function
const suitableRow = findFirstSuitableRow(recordsInDay, startDaysDiff, 1)
recordsInDay[startDaysDiff][suitableRow] = true
recordsInRange.push({
...record,
rowMeta: {
...record.rowMeta,
range: range as any,
id,
position: 'rounded',
style: {
width: `calc(${perDayWidth}px)`,
left: `${startDaysDiff * perDayWidth}px`,
top: `${suitableRow * 40}px`,
},
},
})
}
}
})
return recordsInRange
})
const dragElement = ref<HTMLElement | null>(null)
const resizeInProgress = ref(false)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const isDragging = ref(false)
const dragRecord = ref<Row>()
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>(null)
const hoverRecord = ref<string | null>()
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
// This function is used to calculate the new start and end date of a record when resizing
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value) return
const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position
const percentX = (event.clientX - left - window.scrollX) / width
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
if (!fromCol || !toCol) return
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const day = Math.floor(percentX * 7)
let updateProperty: string[] = []
let updateRecord: Row
if (resizeDirection.value === 'right') {
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day')
updateProperty = [toCol.title!]
// If the new end date is before the start date, we need to adjust the end date to the start date
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) {
newEndDate = ogStartDate.clone()
}
if (!newEndDate.isValid()) return
updateRecord = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'),
},
}
} else if (resizeDirection.value === 'left') {
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day')
updateProperty = [fromCol.title!]
// If the new start date is after the end date, we need to adjust the start date to the end date
if (dayjs(newStartDate).isAfter(ogEndDate)) {
newStartDate = dayjs(dayjs(ogEndDate)).clone()
}
if (!newStartDate) return
updateRecord = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
// Update the record in the store
const newPk = extractPkFromRow(updateRecord.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? updateRecord : r
})
useDebouncedRowUpdate(updateRecord, updateProperty, false)
}
const onResizeEnd = () => {
resizeInProgress.value = false
resizeDirection.value = null
resizeRecord.value = null
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
}
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
resizeInProgress.value = true
resizeDirection.value = direction
resizeRecord.value = record
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', onResizeEnd)
}
// This method is used to calculate the new start and end date of a record when dragging and dropping
const calculateNewRow = (event: MouseEvent, updateSideBarData?: boolean) => {
const { width, left } = container.value.getBoundingClientRect()
// Calculate the percentage of the width based on the mouse position
// This is used to calculate the day index
const percentX = (event.clientX - left - window.scrollX) / width
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
if (!fromCol) return { updatedProperty: [], newRow: null }
// Calculate the day index based on the percentage of the width
// The day index is a number between 0 and 6
const day = Math.floor(percentX * 7)
// Calculate the new start date based on the day index by adding the day index to the start date of the selected date range
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day')
if (!newStartDate) return { updatedProperty: [], newRow: null }
let endDate
const newRow = {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
const updateProperty = [fromCol.title!]
if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null
// Calculate the new end date based on the day index by adding the day index to the start date of the selected date range
// If the record has an end date, we need to calculate the new end date based on the difference between the start and end date
// If the record doesn't have an end date, we need to calculate the new end date based on the start date
// If the record has an end date and no start Date, we set the end date to the start date
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol.title!)
}
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (updateSideBarData) {
// If the record is being dragged from the sidebar, we need to remove the record from the sidebar data
// and add the new record to the calendar data
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
} else {
// If the record is being dragged within the calendar, we need to update the record in the calendar data
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
}
return { updateProperty, newRow }
}
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit')) return
if (!container.value || !dragRecord.value) return
calculateNewRow(event)
}
const stopDrag = (event: MouseEvent) => {
event.preventDefault()
clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit')) return
if (!isDragging.value || !container.value || !dragRecord.value) return
const { updateProperty, newRow } = calculateNewRow(event)
if (!newRow) return
// Open drop the record, we reset the opacity of the other records
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
if (resizeInProgress.value) return
let target = event.target as HTMLElement
isDragging.value = false
dragTimeout.value = setTimeout(() => {
isDragging.value = true
while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
el.style.opacity = '30%'
}
})
dragRecord.value = record
isDragging.value = true
dragElement.value = target
dragRecord.value = record
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}, 200)
const onMouseUp = () => {
clearTimeout(dragTimeout.value!)
document.removeEventListener('mouseup', onMouseUp)
if (!isDragging.value) {
emits('expandRecord', record)
}
}
document.addEventListener('mouseup', onMouseUp)
}
const dropEvent = (event: DragEvent) => {
if (!isUIAllowed('dataEdit')) return
event.preventDefault()
const data = event.dataTransfer?.getData('text/plain')
if (data) {
const {
record,
}: {
record: Row
} = JSON.parse(data)
dragRecord.value = record
const { updateProperty, newRow } = calculateNewRow(event, true)
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
updateRowProperty(newRow, updateProperty, false)
dragRecord.value = null
}
}
</script>
<template>
<div class="flex relative flex-col prevent-select" data-testid="nc-calendar-week-view" @drop="dropEvent">
<div class="flex">
<div
v-for="(date, weekIndex) in weekDates"
:key="weekIndex"
:class="{
'!border-brand-500 !border-b-gray-200': dayjs(date).isSame(selectedDate, 'day'),
}"
class="w-1/7 text-center text-sm text-gray-500 w-full py-1 border-gray-200 border-l-gray-50 border-t-gray-50 last:border-r-0 border-1 bg-gray-50"
>
{{ dayjs(date).format('DD ddd') }}
</div>
</div>
<div ref="container" class="flex h-[calc(100vh-11.6rem)]">
<div
v-for="(date, dateIndex) in weekDates"
:key="dateIndex"
:class="{
'!border-1 !border-t-0 border-brand-500': dayjs(date).isSame(selectedDate, 'day'),
}"
class="flex flex-col border-r-1 min-h-[100vh] last:border-r-0 items-center w-1/7"
data-testid="nc-calendar-week-day"
@click="selectedDate = dayjs(date)"
></div>
</div>
<div
class="absolute nc-scrollbar-md overflow-y-auto mt-9 pointer-events-none inset-0"
data-testid="nc-calendar-week-record-container"
>
<div
v-for="(record, id) in calendarData"
:key="id"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta.id"
:style="{
...record.rowMeta.style,
boxShadow:
record.rowMeta.id === dragElement?.getAttribute('data-unique-id')
? '0px 8px 8px -4px rgba(0, 0, 0, 0.04), 0px 20px 24px -4px rgba(0, 0, 0, 0.10)'
: 'none',
}"
class="absolute group draggable-record pointer-events-auto nc-calendar-week-record-card"
@mousedown="dragStart($event, record)"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id"
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarRecordCard
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta.position"
:record="record"
:selected="
dragRecord
? dragRecord.rowMeta.id === record.rowMeta.id
: resizeRecord
? resizeRecord.rowMeta.id === record.rowMeta.id
: false
"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
color="blue"
@dblclick="emits('expand-record', record)"
@resize-start="onResizeStart"
>
<template v-if="!isRowEmpty(record, displayField)">
<div
:class="{
'mt-2': displayField.uidt === UITypes.SingleLineText,
'mt-1': displayField.uidt === UITypes.MultiSelect || displayField.uidt === UITypes.SingleSelect,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField)"
v-model="record.row[displayField.title]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField.title]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarRecordCard>
</LazySmartsheetRow>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.prevent-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

783
packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue

@ -0,0 +1,783 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { computed, ref } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord'])
const {
selectedDateRange,
formattedData,
formattedSideBarData,
calendarRange,
displayField,
selectedTime,
selectedDate,
updateRowProperty,
sideBarFilterOption,
showSideMenu,
} = useCalendarViewStoreOrThrow()
const container = ref<null | HTMLElement>(null)
const scrollContainer = ref<null | HTMLElement>(null)
const { width: containerWidth } = useElementSize(container)
const { isUIAllowed } = useRoles()
const meta = inject(MetaInj, ref())
// Since it is a datetime Week view, we need to create a 2D array of dayjs objects to represent the hours in a day for each day in the week
const datesHours = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = []
let startOfWeek = dayjs(selectedDateRange.value.start) ?? dayjs().startOf('week')
const endOfWeek = dayjs(selectedDateRange.value.end) ?? dayjs().endOf('week')
while (startOfWeek.isSameOrBefore(endOfWeek)) {
const hours: Array<dayjs.Dayjs> = []
for (let i = 0; i < 24; i++) {
hours.push(
dayjs()
.hour(i)
.minute(0)
.second(0)
.millisecond(0)
.year(startOfWeek.year())
.month(startOfWeek.month())
.date(startOfWeek.date()),
)
}
datesHours.push(hours)
startOfWeek = startOfWeek.add(1, 'day')
}
return datesHours
})
const recordsAcrossAllRange = computed<{
records: Array<Row>
count: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
}
}
}
}>(() => {
if (!formattedData.value || !calendarRange.value || !container.value) return { records: [], count: {} }
const { scrollHeight } = scrollContainer.value
const perWidth = containerWidth.value / 7
const perHeight = scrollHeight / 24
const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day')
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day')
// We need to keep track of the overlaps for each day and hour in the week to calculate the width and left position of each record
// The first key is the date, the second key is the hour, and the value is an object containing the ids of the records that overlap
// The key is in the format YYYY-MM-DD and the hour is in the format HH:mm
const overlaps: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
}
}
} = {}
let recordsToDisplay: Array<Row> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value].filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
}
return false
})
sortedFormattedData.forEach((record: Row) => {
if (!toCol && fromCol) {
// If there is no toColumn chosen in the range
const startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
if (!startDate) return
// Hour Key currently is set as start of the hour
// TODO: Need to work on the granularity of the hour
const dateKey = startDate?.format('YYYY-MM-DD')
const hourKey = startDate?.startOf('hour').format('HH:mm')
const id = record.rowMeta.id ?? generateRandomNumber()
let style: Partial<CSSStyleDeclaration> = {}
// If the dateKey and hourKey are valid, we add the id to the overlaps object
if (dateKey && hourKey) {
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
}
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
}
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = dayjs(dateKey).day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
// We calculate the index of the hour in the day and set the top and height of the record
const hourIndex = Math.min(
Math.max(
datesHours.value[dayIndex].findIndex((h) => h.startOf('hour').format('HH:mm') === hourKey),
0,
),
23,
)
style = {
...style,
top: `${hourIndex * perHeight - hourIndex - hourIndex * 0.15}px`,
height: `${perHeight - 2}px`,
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
})
} else if (fromCol && toCol) {
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
let endDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
// If the start date is not valid, we skip the record
if (!startDate?.isValid()) return
// If the end date is not valid, we set it to 30 minutes after the start date
if (!endDate?.isValid()) {
endDate = startDate.clone().add(30, 'minutes')
}
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart
}
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
}
// Setting the current start date to the start date of the record
let currentStartDate: dayjs.Dayjs = startDate.clone()
// We loop through the start date to the end date and create a record for each day as it spans bottom to top
while (currentStartDate.isSameOrBefore(endDate!, 'day')) {
const currentEndDate = currentStartDate.clone().endOf('day')
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate
const dateKey = recordStart.format('YYYY-MM-DD')
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = recordStart.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
// We calculate the index of the start and end hour in the day
const startHourIndex = Math.max(
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordStart.format('HH:mm')),
0,
)
const endHourIndex = Math.max(
(datesHours.value[dayIndex] ?? []).findIndex((h) => h.format('HH:mm') === recordEnd?.startOf('hour').format('HH:mm')),
0,
)
let position: 'topRounded' | 'bottomRounded' | 'rounded' | 'none' = 'rounded'
const isSelectedDay = (date: dayjs.Dayjs) => date.isSame(currentStartDate, 'day')
const isBeforeSelectedDay = (date: dayjs.Dayjs) => date.isBefore(currentStartDate, 'day')
const isAfterSelectedDay = (date: dayjs.Dayjs) => date.isAfter(currentStartDate, 'day')
if (isSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'rounded'
} else if (isBeforeSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'none'
} else if (isSelectedDay(startDate) && isAfterSelectedDay(endDate)) {
position = 'topRounded'
} else if (isBeforeSelectedDay(startDate) && isSelectedDay(endDate)) {
position = 'bottomRounded'
} else {
position = 'none'
}
let _startHourIndex = startHourIndex
let style: Partial<CSSStyleDeclaration> = {}
// We loop through the start hour index to the end hour index and add the id to the overlaps object
while (_startHourIndex <= endHourIndex) {
const hourKey = datesHours.value[dayIndex][_startHourIndex].format('HH:mm')
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
}
_startHourIndex++
}
const spanHours = endHourIndex - startHourIndex + 1
const top = startHourIndex * perHeight
const height = (endHourIndex - startHourIndex + 1) * perHeight - spanHours - 5
style = {
...style,
top: `${top}px`,
height: `${height}px`,
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
style,
range,
position,
dayIndex,
},
})
// We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0)
}
}
})
// With can't find the left and width of the record without knowing the overlaps
// Hence the first iteration is to find the overlaps, top, height and then the second iteration is to find the left and width
// This is because the left and width of the record depends on the overlaps
recordsToDisplay = recordsToDisplay.map((record) => {
// maxOverlaps is the maximum number of records that overlap in a single hour
// overlapIndex is the index of the record in the overlaps object
let maxOverlaps = 1
let overlapIndex = 0
const dayIndex = record.rowMeta.dayIndex as number
const dateKey = dayjs(selectedDateRange.value.start).add(dayIndex, 'day').format('YYYY-MM-DD')
for (const hours in overlaps[dateKey]) {
// We are checking if the overlaps object contains the id of the record
// If it does, we set the maxOverlaps and overlapIndex
if (overlaps[dateKey][hours].id.includes(record.rowMeta.id!)) {
maxOverlaps = Math.max(maxOverlaps, overlaps[dateKey][hours].id.length - overlaps[dateKey][hours].overflowCount)
overlapIndex = Math.max(overlapIndex, overlaps[dateKey][hours].id.indexOf(record.rowMeta.id!))
}
}
const spacing = 1
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps / 7
const leftPerRecord = widthPerRecord * overlapIndex
record.rowMeta.style = {
...record.rowMeta.style,
left: `calc(${dayIndex * perWidth}px + ${leftPerRecord}%)`,
width: `calc(${widthPerRecord}%)`,
}
return record
})
})
return {
records: recordsToDisplay,
count: overlaps,
}
})
const dragElement = ref<HTMLElement | null>(null)
const resizeInProgress = ref(false)
const dragTimeout = ref<ReturnType<typeof setTimeout>>()
const hoverRecord = ref<string | null>()
const resizeDirection = ref<'right' | 'left' | null>()
const resizeRecord = ref<Row | null>()
const isDragging = ref(false)
const dragRecord = ref<Row>()
const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[], isDelete: boolean) => {
updateRowProperty(row, updateProperty, isDelete)
}, 500)
const onResize = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !container.value || !resizeRecord.value || !scrollContainer.value) return
const { width, left, top, bottom } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
// If the mouse is near the bottom of the container, we scroll down
// If the mouse is near the top of the container, we scroll up
if (event.clientY > bottom - 20) {
container.value.scrollTop += 10
} else if (event.clientY < top + 20) {
container.value.scrollTop -= 10
}
// We calculate the percentage of the mouse position in the container
// percentX is used for the day and percentY is used for the hour
const percentX = (event.clientX - left - window.scrollX) / width
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight
const fromCol = resizeRecord.value.rowMeta.range?.fk_from_col
const toCol = resizeRecord.value.rowMeta.range?.fk_to_col
if (!fromCol || !toCol) return
const ogEndDate = dayjs(resizeRecord.value.row[toCol.title!])
const ogStartDate = dayjs(resizeRecord.value.row[fromCol.title!])
const day = Math.floor(percentX * 7)
const hour = Math.floor(percentY * 23)
let updateProperty: string[] = []
let newRow: Row = resizeRecord.value
if (resizeDirection.value === 'right') {
let newEndDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour')
updateProperty = [toCol.title!]
// If the new end date is before the start date, we set the new end date to the start date
if (dayjs(newEndDate).isBefore(ogStartDate, 'day')) {
newEndDate = ogStartDate.clone()
}
if (!newEndDate.isValid()) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[toCol.title!]: newEndDate.format('YYYY-MM-DD HH:mm:ssZ'),
},
}
} else if (resizeDirection.value === 'left') {
let newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour')
updateProperty = [fromCol.title!]
// If the new start date is after the end date, we set the new start date to the end date
if (dayjs(newStartDate).isAfter(ogEndDate)) {
newStartDate = dayjs(dayjs(ogEndDate)).clone()
}
if (!newStartDate) return
newRow = {
...resizeRecord.value,
row: {
...resizeRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
}
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
useDebouncedRowUpdate(newRow, updateProperty, false)
}
const onResizeEnd = () => {
resizeInProgress.value = false
resizeDirection.value = null
resizeRecord.value = null
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', onResizeEnd)
}
const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
resizeInProgress.value = true
resizeDirection.value = direction
resizeRecord.value = record
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', onResizeEnd)
}
// We calculate the new row based on the mouse position and update the record
// We also update the sidebar data if the dropped from the sidebar
const calculateNewRow = (
event: MouseEvent,
updateSideBar?: boolean,
): {
newRow: Row | null
updatedProperty: string[]
} => {
if (!isUIAllowed('dataEdit') || !container.value || !dragRecord.value) return { newRow: null, updatedProperty: [] }
const { width, left, top } = container.value.getBoundingClientRect()
const { scrollHeight } = container.value
const percentX = (event.clientX - left - window.scrollX) / width
const percentY = (event.clientY - top + container.value.scrollTop) / scrollHeight
const fromCol = dragRecord.value.rowMeta.range?.fk_from_col
const toCol = dragRecord.value.rowMeta.range?.fk_to_col
if (!fromCol) return { newRow: null, updatedProperty: [] }
const day = Math.max(0, Math.min(6, Math.floor(percentX * 7)))
const hour = Math.max(0, Math.min(23, Math.floor(percentY * 24)))
const newStartDate = dayjs(selectedDateRange.value.start).add(day, 'day').add(hour, 'hour')
if (!newStartDate) return { newRow: null, updatedProperty: [] }
let endDate
const updatedProperty = [fromCol.title!]
const newRow = {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
},
}
if (toCol) {
const fromDate = dragRecord.value.row[fromCol.title!] ? dayjs(dragRecord.value.row[fromCol.title!]) : null
const toDate = dragRecord.value.row[toCol.title!] ? dayjs(dragRecord.value.row[toCol.title!]) : null
if (fromDate && toDate) {
endDate = dayjs(newStartDate).add(toDate.diff(fromDate, 'day'), 'day')
} else if (fromDate && !toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else if (!fromDate && toDate) {
endDate = dayjs(newStartDate).endOf('day')
} else {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
updatedProperty.push(toCol.title!)
}
if (!newRow) return { newRow: null, updatedProperty }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
if (updateSideBar) {
formattedData.value = [...formattedData.value, newRow]
formattedSideBarData.value = formattedSideBarData.value.filter((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
} else {
formattedData.value = formattedData.value.map((r) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
}
return { newRow, updatedProperty }
}
const onDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !scrollContainer.value || !dragRecord.value) return
const containerRect = scrollContainer.value.getBoundingClientRect()
const scrollBottomThreshold = 20
if (event.clientY > containerRect.bottom - scrollBottomThreshold) {
scrollContainer.value.scrollTop += 10
} else if (event.clientY < containerRect.top + scrollBottomThreshold) {
scrollContainer.value.scrollTop -= 10
}
calculateNewRow(event)
}
const stopDrag = (event: MouseEvent) => {
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return
event.preventDefault()
clearTimeout(dragTimeout.value!)
const { newRow, updatedProperty } = calculateNewRow(event, false)
// We set the visibility and opacity of the records back to normal
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
el.style.visibility = ''
el.style.opacity = '100%'
})
if (dragElement.value) {
dragElement.value.style.boxShadow = 'none'
dragElement.value = null
}
if (newRow) {
updateRowProperty(newRow, updatedProperty, false)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const dragStart = (event: MouseEvent, record: Row) => {
if (!isUIAllowed('dataEdit')) return
if (resizeInProgress.value) return
let target = event.target as HTMLElement
isDragging.value = false
dragTimeout.value = setTimeout(() => {
isDragging.value = true
while (!target.classList.contains('draggable-record')) {
target = target.parentElement as HTMLElement
}
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {
if (!el.getAttribute('data-unique-id').includes(record.rowMeta.id!)) {
// el.style.visibility = 'hidden'
el.style.opacity = '30%'
}
})
dragRecord.value = record
isDragging.value = true
dragElement.value = target
dragRecord.value = record
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}, 200)
const onMouseUp = () => {
clearTimeout(dragTimeout.value!)
document.removeEventListener('mouseup', onMouseUp)
if (!isDragging.value) {
emits('expandRecord', record)
}
}
document.addEventListener('mouseup', onMouseUp)
}
const dropEvent = (event: DragEvent) => {
if (!isUIAllowed('dataEdit') || !container.value) return
event.preventDefault()
const data = event.dataTransfer?.getData('text/plain')
if (data) {
const {
record,
}: {
record: Row
} = JSON.parse(data)
dragRecord.value = record
const { newRow, updatedProperty } = calculateNewRow(event, true)
if (newRow) {
updateRowProperty(newRow, updatedProperty, false)
}
}
}
const viewMore = (hour: dayjs.Dayjs) => {
sideBarFilterOption.value = 'selectedHours'
selectedTime.value = hour
showSideMenu.value = true
}
</script>
<template>
<div
ref="scrollContainer"
class="h-[calc(100vh-9.9rem)] prevent-select relative flex w-full overflow-y-auto nc-scrollbar-md"
data-testid="nc-calendar-week-view"
@drop="dropEvent"
>
<div class="flex sticky h-7.1 z-1 top-0 pl-16 bg-gray-50 w-full">
<div
v-for="date in datesHours"
:key="date[0].toISOString()"
:class="{
'text-brand-500': date[0].isSame(dayjs(), 'date'),
}"
class="w-1/7 text-center text-sm text-gray-500 w-full py-1 border-gray-200 last:border-r-0 border-b-0 border-l-1 border-r-0 bg-gray-50"
>
{{ dayjs(date[0]).format('DD ddd') }}
</div>
</div>
<div class="absolute bg-white w-16 z-1">
<div
v-for="(hour, index) in datesHours[0]"
:key="index"
class="h-20 first:mt-0 pt-7.1 text-center text-xs text-gray-500 py-1"
>
{{ hour.format('h A') }}
</div>
</div>
<div ref="container" class="absolute ml-16 flex w-[calc(100%-64px)]">
<div v-for="(date, index) in datesHours" :key="index" class="h-full w-1/7" data-testid="nc-calendar-week-day">
<div
v-for="(hour, hourIndex) in date"
:key="hourIndex"
:class="{
'border-1 !border-brand-500 bg-gray-50': hour.isSame(selectedTime, 'hour'),
}"
class="text-center relative first:mt-7.1 h-20 text-sm text-gray-500 w-full hover:bg-gray-50 py-1 border-transparent border-1 border-x-gray-200 border-t-gray-200"
data-testid="nc-calendar-week-hour"
@click="
() => {
selectedTime = hour
selectedDate = hour
}
"
>
<NcButton
v-if="recordsAcrossAllRange?.count?.[hour.format('YYYY-MM-DD')]?.[hour.format('HH:mm')]?.overflow"
class="!absolute bottom-1 text-center w-15 ml-auto inset-x-0 z-3 text-gray-500"
size="xxsmall"
type="secondary"
@click="viewMore(hour)"
>
<span class="text-xs">
+
{{ recordsAcrossAllRange?.count[hour.format('YYYY-MM-DD')][hour.format('HH:mm')]?.overflowCount }}
more
</span>
</NcButton>
</div>
</div>
<div
class="absolute pointer-events-none inset-0 overflow-hidden !mt-[29px]"
data-testid="nc-calendar-week-record-container"
>
<div
v-for="(record, rowIndex) in recordsAcrossAllRange.records"
:key="rowIndex"
:data-testid="`nc-calendar-week-record-${record.row[displayField!.title!]}`"
:data-unique-id="record.rowMeta!.id"
:style="record.rowMeta!.style "
class="absolute draggable-record w-1/7 group cursor-pointer pointer-events-auto"
@mousedown="dragStart($event, record)"
@mouseleave="hoverRecord = null"
@mouseover="hoverRecord = record.rowMeta.id"
@dragover.prevent
>
<LazySmartsheetRow :row="record">
<LazySmartsheetCalendarVRecordCard
:hover="hoverRecord === record.rowMeta.id"
:position="record.rowMeta!.position"
:record="record"
:resize="!!record.rowMeta.range?.fk_to_col && isUIAllowed('dataEdit')"
color="blue"
>
<template v-if="!isRowEmpty(record, displayField)">
<div
:class="{
'!mt-2': displayField!.uidt === UITypes.SingleLineText,
'!mt-1': displayField!.uidt === UITypes.MultiSelect || displayField!.uidt === UITypes.SingleSelect,
}"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField!)"
v-model="record.row[displayField!.title!]"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField!.title!]"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</div>
</template>
</LazySmartsheetCalendarVRecordCard>
</LazySmartsheetRow>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.prevent-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

31
packages/nc-gui/components/smartsheet/calendar/YearView.vue

@ -0,0 +1,31 @@
<script lang="ts" setup>
const { selectedDate, activeDates } = useCalendarViewStoreOrThrow()
const months = computed(() => {
const months = []
for (let i = 0; i < 12; i++) {
months.push(selectedDate.value.set('month', i).set('date', 1))
}
return months
})
</script>
<template>
<div
class="flex flex-wrap gap-6 pb-4 items-center justify-center overflow-auto nc-scrollbar-md"
data-testid="nc-calendar-year-view"
>
<NcDateWeekSelector
v-for="(_, index) in months"
:key="index"
v-model:active-dates="activeDates"
v-model:page-date="months[index]"
v-model:selected-date="selectedDate"
class="max-w-[350px]"
data-testid="nc-calendar-year-view-month-selector"
disable-pagination
/>
</div>
</template>
<style lang="scss" scoped></style>

320
packages/nc-gui/components/smartsheet/calendar/index.vue

@ -0,0 +1,320 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { UITypes } from 'nocodb-sdk'
import {
ActiveViewInj,
IsCalendarInj,
IsFormInj,
IsGalleryInj,
IsGridInj,
IsKanbanInj,
MetaInj,
ReloadViewDataHookInj,
ReloadViewMetaHookInj,
type Row as RowType,
computed,
extractPkFromRow,
inject,
provide,
ref,
rowDefaultData,
} from '#imports'
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const { isMobileMode } = useGlobal()
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(false))
provide(IsGridInj, ref(false))
provide(IsKanbanInj, ref(false))
provide(IsCalendarInj, ref(true))
const {
calDataType,
loadCalendarMeta,
loadCalendarData,
loadSidebarData,
isCalendarDataLoading,
selectedDate,
selectedMonth,
activeDates,
pageDate,
showSideMenu,
selectedDateRange,
activeCalendarView,
paginateCalendarView,
} = useCalendarViewStoreOrThrow()
const calendarRangeDropdown = ref(false)
const router = useRouter()
const route = useRoute()
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
rowId: undefined,
},
})
},
})
const expandedFormDlg = ref(false)
const expandedFormRow = ref<RowType>()
const expandedFormRowState = ref<Record<string, any>>()
const expandRecord = (row: RowType, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
router.push({
query: {
...route.query,
rowId,
},
})
} else {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
}
const newRecord = (row: RowType) => {
// TODO: The default values has to be filled based on the active calendar view
// and selected sidebar filter option
expandRecord({
row: {
...rowDefaultData(meta.value?.columns),
...row.row,
},
oldRow: {},
rowMeta: {
new: true,
},
})
}
onMounted(async () => {
await loadCalendarMeta()
await loadCalendarData()
if (!activeCalendarView.value) {
activeCalendarView.value = 'month'
}
})
reloadViewMetaHook?.on(async () => {
await loadCalendarMeta()
})
reloadViewDataHook?.on(async () => {
await Promise.all([loadCalendarData(), loadSidebarData()])
})
const goToToday = () => {
selectedDate.value = dayjs()
pageDate.value = dayjs()
selectedMonth.value = dayjs()
selectedDateRange.value = {
start: dayjs().startOf('week'),
end: dayjs().endOf('week'),
}
}
const headerText = computed(() => {
switch (activeCalendarView.value) {
case 'day':
return dayjs(selectedDate.value).format('D MMMM YYYY')
case 'week':
if (selectedDateRange.value.start.isSame(selectedDateRange.value.end, 'month')) {
return `${selectedDateRange.value.start.format('D')} - ${selectedDateRange.value.end.format('D MMM YY')}`
} else if (selectedDateRange.value.start.isSame(selectedDateRange.value.end, 'year')) {
return `${selectedDateRange.value.start.format('D MMM')} - ${selectedDateRange.value.end.format('D MMM YY')}`
} else {
return `${selectedDateRange.value.start.format('D MMM YY')} - ${selectedDateRange.value.end.format('D MMM YY')}`
}
case 'month':
return dayjs(selectedMonth.value).format('MMMM YYYY')
case 'year':
return dayjs(selectedDate.value).format('YYYY')
}
})
</script>
<template>
<div class="flex h-full flex-row" data-testid="nc-calendar-wrapper">
<div class="flex flex-col w-full">
<div class="flex justify-between p-3 items-center border-b-1 border-gray-200" data-testid="nc-calendar-topbar">
<div class="flex justify-start gap-3 items-center">
<NcTooltip>
<template #title> {{ $t('labels.previous') }}</template>
<NcButton
v-e="`['c:calendar:calendar-${activeCalendarView}-prev-btn']`"
data-testid="nc-calendar-prev-btn"
size="small"
type="secondary"
@click="paginateCalendarView('prev')"
>
<component :is="iconMap.doubleLeftArrow" class="h-4 w-4" />
</NcButton>
</NcTooltip>
<NcDropdown v-model:visible="calendarRangeDropdown" :auto-close="false" :trigger="['click']">
<NcButton :class="{ '!w-24': activeCalendarView === 'year' }" class="w-45" full-width size="small" type="secondary">
<div class="flex w-full px-3 py-1 w-full items-center justify-between">
<span class="font-bold text-center text-brand-500" data-testid="nc-calendar-active-date">{{ headerText }}</span>
<component :is="iconMap.arrowDown" class="h-4 w-4 text-gray-700" />
</div>
</NcButton>
<template #overlay>
<div v-if="calendarRangeDropdown" class="min-w-[22.1rem]" @click.stop>
<NcDateWeekSelector
v-if="activeCalendarView === ('day' as const)"
v-model:active-dates="activeDates"
v-model:page-date="pageDate"
v-model:selected-date="selectedDate"
/>
<NcDateWeekSelector
v-else-if="activeCalendarView === ('week' as const)"
v-model:active-dates="activeDates"
v-model:page-date="pageDate"
v-model:selected-week="selectedDateRange"
is-week-picker
/>
<NcMonthYearSelector
v-else-if="activeCalendarView === ('month' as const)"
v-model:page-date="pageDate"
v-model:selected-date="selectedMonth"
/>
<NcMonthYearSelector
v-else-if="activeCalendarView === ('year' as const)"
v-model:page-date="pageDate"
v-model:selected-date="selectedDate"
is-year-picker
/>
</div>
</template>
</NcDropdown>
<NcTooltip>
<template #title> {{ $t('labels.next') }}</template>
<NcButton
v-e="`['c:calendar:calendar-${activeCalendarView}-next-btn']`"
data-testid="nc-calendar-next-btn"
size="small"
type="secondary"
@click="paginateCalendarView('next')"
>
<component :is="iconMap.doubleRightArrow" class="h-4 w-4" />
</NcButton>
</NcTooltip>
<NcButton
v-if="!isMobileMode"
v-e="`['c:calendar:calendar-${activeCalendarView}-today-btn']`"
data-testid="nc-calendar-today-btn"
size="small"
type="secondary"
@click="goToToday"
>
{{ $t('activity.goToToday') }}
</NcButton>
<span class="opacity-0" data-testid="nc-active-calendar-view">
{{ activeCalendarView }}
</span>
</div>
<NcTooltip>
<template #title> {{ $t('activity.toggleSidebar') }}</template>
<NcButton
v-if="!isMobileMode"
v-e="`['c:calendar:calendar-${activeCalendarView}-toggle-sidebar']`"
data-testid="nc-calendar-side-bar-btn"
size="small"
type="secondary"
@click="showSideMenu = !showSideMenu"
>
<component :is="iconMap.sidebar" class="h-4 w-4 text-gray-700 transition-all" />
</NcButton>
</NcTooltip>
</div>
<LazySmartsheetCalendarYearView v-if="activeCalendarView === 'year'" />
<template v-if="!isCalendarDataLoading">
<LazySmartsheetCalendarMonthView
v-if="activeCalendarView === 'month'"
@expand-record="expandRecord"
@new-record="newRecord"
/>
<LazySmartsheetCalendarWeekViewDateField
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.Date"
@expand-record="expandRecord"
@new-record="newRecord"
/>
<LazySmartsheetCalendarWeekViewDateTimeField
v-else-if="activeCalendarView === 'week' && calDataType === UITypes.DateTime"
@expand-record="expandRecord"
@new-record="newRecord"
/>
<LazySmartsheetCalendarDayViewDateField
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.Date"
@expand-record="expandRecord"
@new-record="newRecord"
/>
<LazySmartsheetCalendarDayViewDateTimeField
v-else-if="activeCalendarView === 'day' && calDataType === UITypes.DateTime"
@expand-record="expandRecord"
@new-record="newRecord"
/>
</template>
<div v-if="isCalendarDataLoading && activeCalendarView !== 'year'" class="flex w-full items-center h-full justify-center">
<GeneralLoader size="xlarge" />
</div>
</div>
<LazySmartsheetCalendarSideMenu
v-if="!isMobileMode"
:visible="showSideMenu"
@expand-record="expandRecord"
@new-record="newRecord"
/>
</div>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:meta="meta"
:row="expandedFormRow"
:state="expandedFormRowState"
:view="view"
/>
</Suspense>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
v-model="expandedFormOnRowIdDlg"
:meta="meta"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
</template>

90
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -101,6 +101,8 @@ const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
@ -211,12 +213,14 @@ const save = async () => {
kanbanClbk,
})
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
} else {
await _save(undefined, undefined, {
kanbanClbk,
})
_loadRow()
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
}
isUnsavedFormExist.value = false
@ -507,14 +511,14 @@ export default {
<template>
<NcModal
:visible="isExpanded"
:footer="null"
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(80vw,1280px)' : 'min(80vw,1280px)'"
:body-style="{ padding: 0 }"
:class="{ active: isExpanded }"
:closable="false"
size="small"
:footer="null"
:visible="isExpanded"
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(80vw,1280px)' : 'min(80vw,1280px)'"
class="nc-drawer-expanded-form"
:class="{ active: isExpanded }"
size="small"
@update:visible="onIsExpandedUpdate"
>
<div class="h-[85vh] xs:(max-h-full) max-h-215 flex flex-col p-6">
@ -525,8 +529,8 @@ export default {
<NcButton
v-if="props.showNextPrevIcons"
:disabled="isFirstRow"
type="secondary"
class="nc-prev-arrow !w-10"
type="secondary"
@click="$emit('prev')"
>
<MdiChevronUp class="text-md" />
@ -534,15 +538,15 @@ export default {
<NcButton
v-if="props.showNextPrevIcons"
:disabled="islastRow"
type="secondary"
class="nc-next-arrow !w-10"
type="secondary"
@click="onNext"
>
<MdiChevronDown class="text-md" />
</NcButton>
</div>
<div v-if="isLoading">
<a-skeleton-input class="!h-8 !sm:mr-14 !w-52 mt-1 !rounded-md !overflow-hidden" active size="small" />
<a-skeleton-input active class="!h-8 !sm:mr-14 !w-52 mt-1 !rounded-md !overflow-hidden" size="small" />
</div>
<div
v-if="row.rowMeta?.new || props.newRecordHeader"
@ -559,9 +563,9 @@ export default {
<div class="flex gap-2">
<NcButton
v-if="!isNew && rowId"
type="secondary"
class="!<lg:hidden text-gray-700"
:disabled="isLoading"
class="!<lg:hidden text-gray-700"
type="secondary"
@click="copyRecordUrl()"
>
<div v-e="['c:row-expand:copy-url']" data-testid="nc-expanded-form-copy-url" class="flex gap-2 items-center">
@ -597,8 +601,8 @@ export default {
<NcMenuItem v-if="isUIAllowed('dataEdit')" class="text-gray-700" @click="!isNew ? onDuplicateRow() : () => {}">
<div
v-e="['c:row-expand:duplicate']"
data-testid="nc-expanded-form-duplicate"
class="flex gap-2 items-center"
data-testid="nc-expanded-form-duplicate"
>
<component :is="iconMap.copy" class="cursor-pointer nc-duplicate-row" />
<span class="-ml-0.25">
@ -612,7 +616,7 @@ export default {
class="!text-red-500 !hover:bg-red-50"
@click="!isNew && onDeleteRowClick()"
>
<div v-e="['c:row-expand:delete']" data-testid="nc-expanded-form-delete" class="flex gap-2 items-center">
<div v-e="['c:row-expand:delete']" class="flex gap-2 items-center" data-testid="nc-expanded-form-delete">
<component :is="iconMap.delete" class="cursor-pointer nc-delete-row" />
<span class="-ml-0.25">
{{ $t('activity.deleteRecord') }}
@ -623,12 +627,12 @@ export default {
</template>
</NcDropdown>
<NcButton
type="secondary"
class="nc-expand-form-close-btn w-10"
data-testid="nc-expanded-form-close"
type="secondary"
@click="onClose"
>
<GeneralIcon icon="close" class="text-md text-gray-700" />
<GeneralIcon class="text-md text-gray-700" icon="close" />
</NcButton>
</div>
</template>
@ -637,11 +641,11 @@ export default {
<NcButton
v-if="props.showNextPrevIcons && !isFirstRow"
v-e="['c:row-expand:prev']"
type="secondary"
class="nc-prev-arrow !w-10"
type="secondary"
@click="$emit('prev')"
>
<GeneralIcon icon="arrowLeft" class="text-lg text-gray-700" />
<GeneralIcon class="text-lg text-gray-700" icon="arrowLeft" />
</NcButton>
<div v-else class="min-w-10.5"></div>
<div class="flex flex-grow justify-center items-center font-semibold text-lg">
@ -650,11 +654,11 @@ export default {
<NcButton
v-if="props.showNextPrevIcons && !islastRow"
v-e="['c:row-expand:next']"
type="secondary"
class="nc-next-arrow !w-10"
type="secondary"
@click="onNext"
>
<GeneralIcon icon="arrowRight" class="text-lg text-gray-700" />
<GeneralIcon class="text-lg text-gray-700" icon="arrowRight" />
</NcButton>
<div v-else class="min-w-10.5"></div>
</div>
@ -662,11 +666,11 @@ export default {
</div>
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%-4rem)] w-full gap-4">
<div
class="flex xs:w-full flex-col border-1 rounded-xl overflow-hidden border-gray-200 xs:(border-0 rounded-none)"
:class="{
'w-full': !showRightSections,
'w-2/3': showRightSections,
}"
class="flex xs:w-full flex-col border-1 rounded-xl overflow-hidden border-gray-200 xs:(border-0 rounded-none)"
>
<div
ref="expandedFormScrollWrapper"
@ -676,20 +680,20 @@ export default {
v-for="(col, i) of fields"
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
class="nc-expanded-form-row mt-2 py-2 <lg:w-full"
:class="`nc-expand-col-${col.title}`"
:col-id="col.id"
:data-testid="`nc-expand-col-${col.title}`"
class="nc-expanded-form-row mt-2 py-2 <lg:w-full"
>
<div class="flex items-start flex-row sm:(gap-x-6) <lg:(flex-col w-full) nc-expanded-cell min-h-10">
<div class="w-48 <lg:(w-full) mt-0.25 !h-[35px]">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
class="nc-expanded-cell-header h-full"
:column="col"
class="nc-expanded-cell-header h-full"
/>
<LazySmartsheetHeaderCell v-else class="nc-expanded-cell-header" :column="col" />
<LazySmartsheetHeaderCell v-else :column="col" class="nc-expanded-cell-header" />
</div>
<template v-if="isLoading">
@ -699,8 +703,8 @@ export default {
></div>
<a-skeleton-input
v-else
class="!h-8.5 !xs:h-9.5 !xs:bg-white sm:mr-21 !w-60 mt-0.75 !rounded-lg !overflow-hidden"
active
class="!h-8.5 !xs:h-9.5 !xs:bg-white sm:mr-21 !w-60 mt-0.75 !rounded-lg !overflow-hidden"
size="small"
/>
</template>
@ -708,28 +712,28 @@ export default {
<SmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="bg-white w-80 <lg:w-full px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
:class="{
'!bg-gray-50 !select-text nc-system-field': isReadOnlyVirtualCell(col),
}"
class="bg-white w-80 <lg:w-full px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:row="_row"
:column="col"
:class="{
'px-1': isReadOnlyVirtualCell(col),
}"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:active="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
@ -740,14 +744,14 @@ export default {
<div v-if="hiddenFields.length > 0" class="flex w-full lg:px-12 <lg:(px-1 mt-2) items-center py-3">
<div class="flex-grow h-px mr-1 bg-gray-100"></div>
<NcButton
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
class="flex-shrink !text-sm overflow-hidden"
type="secondary"
@click="toggleHiddenFields"
>
{{ showHiddenFields ? `Hide ${hiddenFields.length} hidden` : `Show ${hiddenFields.length} hidden` }}
{{ hiddenFields.length > 1 ? `fields` : `field` }}
<MdiChevronDown class="ml-1" :class="showHiddenFields ? 'transform rotate-180' : ''" />
<MdiChevronDown :class="showHiddenFields ? 'transform rotate-180' : ''" class="ml-1" />
</NcButton>
<div class="flex-grow h-px ml-1 bg-gray-100"></div>
</div>
@ -756,15 +760,15 @@ export default {
v-for="(col, i) of hiddenFields"
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
class="sm:(mt-2) py-2 <lg:w-full"
:class="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
class="sm:(mt-2) py-2 <lg:w-full"
>
<div class="sm:gap-x-6 flex sm:flex-row <lg:(flex-col w-full) items-start min-h-10">
<div class="sm:w-48 <lg:w-full scale-110 !h-[35px]">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" class="nc-expanded-cell-header" />
<LazySmartsheetHeaderCell v-else class="nc-expanded-cell-header" :column="col" />
<LazySmartsheetHeaderCell v-else :column="col" class="nc-expanded-cell-header" />
</div>
<template v-if="isLoading">
@ -774,8 +778,8 @@ export default {
></div>
<a-skeleton-input
v-else
class="!h-8.5 !xs:h-12 !xs:bg-white sm:mr-21 w-60 mt-0.75 !rounded-lg !overflow-hidden"
active
class="!h-8.5 !xs:h-12 !xs:bg-white sm:mr-21 w-60 mt-0.75 !rounded-lg !overflow-hidden"
size="small"
/>
</template>
@ -788,17 +792,17 @@ export default {
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:row="_row"
:column="col"
:read-only="readOnly"
:row="_row"
/>
<LazySmartsheetCell
v-else
v-model="_row.row[col.title]"
:active="true"
:column="col"
:edit-enabled="true"
:active="true"
:read-only="readOnly"
@update:model-value="changedColumns.add(col.title)"
/>
@ -814,8 +818,8 @@ export default {
class="w-full h-16 border-t-1 border-gray-200 bg-white flex items-center justify-end p-3 xs:(p-0 mt-4 border-t-0 gap-x-4 justify-between)"
>
<NcDropdown v-if="!isNew && isMobileMode" placement="bottomRight">
<NcButton type="secondary" class="nc-expand-form-more-actions w-10" :disabled="isLoading">
<GeneralIcon icon="threeDotVertical" class="text-md" :class="isLoading ? 'text-gray-300' : 'text-gray-700'" />
<NcButton :disabled="isLoading" class="nc-expand-form-more-actions w-10" type="secondary">
<GeneralIcon :class="isLoading ? 'text-gray-300' : 'text-gray-700'" class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
@ -826,7 +830,7 @@ export default {
</div>
</NcMenuItem>
<NcMenuItem v-if="rowId" class="text-gray-700" @click="!isNew ? copyRecordUrl() : () => {}">
<div v-e="['c:row-expand:copy-url']" data-testid="nc-expanded-form-copy-url" class="flex gap-2 items-center">
<div v-e="['c:row-expand:copy-url']" class="flex gap-2 items-center" data-testid="nc-expanded-form-copy-url">
<component :is="iconMap.link" class="cursor-pointer nc-duplicate-row" />
{{ $t('labels.copyRecordURL') }}
</div>
@ -850,21 +854,21 @@ export default {
<div class="flex flex-row gap-x-3">
<NcButton
v-if="isMobileMode"
type="secondary"
size="medium"
data-testid="nc-expanded-form-save"
class="nc-expand-form-save-btn !xs:(text-base)"
data-testid="nc-expanded-form-save"
size="medium"
type="secondary"
@click="onClose"
>
<div class="px-1">Close</div>
</NcButton>
<NcButton
v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
class="nc-expand-form-save-btn !xs:(text-base)"
data-testid="nc-expanded-form-save"
type="primary"
size="medium"
class="nc-expand-form-save-btn !xs:(text-base)"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
@click="save"
>
<div class="xs:px-1">Save</div>
@ -874,8 +878,8 @@ export default {
</div>
<div
v-if="showRightSections"
class="nc-comments-drawer border-1 relative border-gray-200 w-1/3 max-w-125 bg-gray-50 rounded-xl min-w-50 overflow-hidden h-full xs:hidden"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
class="nc-comments-drawer border-1 relative border-gray-200 w-1/3 max-w-125 bg-gray-50 rounded-xl min-w-0 overflow-hidden h-full xs:hidden"
>
<SmartsheetExpandedFormComments :loading="isLoading" />
</div>

3
packages/nc-gui/components/smartsheet/grid/index.vue

@ -5,6 +5,7 @@ import GroupBy from './GroupBy.vue'
import {
ActiveViewInj,
FieldsInj,
IsCalendarInj,
IsFormInj,
IsGalleryInj,
IsGridInj,
@ -95,6 +96,8 @@ provide(IsGalleryInj, ref(false))
provide(IsGridInj, ref(true))
provide(IsCalendarInj, ref(false))
provide(RowHeightInj, rowHeight)
// reload table data reload hook as fallback to rowdatareload

103
packages/nc-gui/components/smartsheet/toolbar/CalendarMode.vue

@ -0,0 +1,103 @@
<script lang="ts" setup>
import { useCalendarViewStoreOrThrow } from '#imports'
const props = defineProps<{
tab?: boolean
}>()
const { changeCalendarView, activeCalendarView } = useCalendarViewStoreOrThrow()
const highlightStyle = ref({ left: '0px' })
const setActiveCalendarMode = (mode: 'day' | 'week' | 'month' | 'year', event: MouseEvent) => {
changeCalendarView(mode)
const tabElement = event.target as HTMLElement
highlightStyle.value.left = `${tabElement.offsetLeft}px`
}
const updateHighlightPosition = () => {
nextTick(() => {
const activeTab = document.querySelector('.nc-calendar-mode-tab .tab.active') as HTMLElement
if (activeTab) {
highlightStyle.value.left = `${activeTab.offsetLeft}px`
}
})
}
onMounted(() => {
updateHighlightPosition()
})
watch(activeCalendarView, () => {
if (!props.tab) return
updateHighlightPosition()
})
</script>
<template>
<div
v-if="props.tab"
class="flex flex-row relative p-1 mx-3 mt-3 mb-3 bg-gray-100 rounded-lg gap-x-0.5 nc-calendar-mode-tab"
data-testid="nc-calendar-view-mode"
>
<div :style="highlightStyle" class="highlight"></div>
<div
v-for="mode in ['day', 'week', 'month', 'year']"
:key="mode"
v-e="`['c:calendar:change-calendar-range-${mode}']`"
:class="{ active: activeCalendarView === mode }"
:data-testid="`nc-calendar-view-mode-${mode}`"
class="tab"
@click="setActiveCalendarMode(mode, $event)"
>
<div class="tab-title nc-tab">{{ $t(`objects.${mode}`) }}</div>
</div>
</div>
<div v-else>
<NcDropdown :trigger="['click']">
<NcButton size="small" type="secondary">
{{ $t(`objects.${activeCalendarView}`) }}
<component :is="iconMap.arrowDown" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem
v-for="mode in ['day', 'week', 'month', 'year']"
:key="mode"
v-e="`['c:calendar:change-calendar-range-${mode}']`"
@click="changeCalendarView(mode)"
>
{{ $t(`objects.${mode}`) }}
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
</template>
<style lang="scss" scoped>
.highlight {
@apply absolute h-7.5 w-20 shadow bg-white rounded-lg transition-all duration-300;
z-index: 0;
}
.tab {
@apply flex items-center h-7.5 w-20 z-10 justify-center px-2 py-1 rounded-lg gap-x-1.5 text-gray-500 hover:text-black cursor-pointer transition-all duration-300 select-none;
}
.tab .tab-title {
@apply min-w-0 pointer-events-none;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
line-height: 0.95;
}
.active {
@apply text-black bg-transparent;
}
.nc-calendar-mode-tab {
@apply mr-120 relative;
}
</style>

237
packages/nc-gui/components/smartsheet/toolbar/CalendarRange.vue

@ -0,0 +1,237 @@
<script lang="ts" setup>
import { UITypes, isSystemColumn } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
import { type CalendarRangeType } from '~/lib'
import {
ActiveViewInj,
IsLockedInj,
IsPublicInj,
MetaInj,
computed,
iconMap,
inject,
ref,
useViewColumnsOrThrow,
watch,
} from '#imports'
const meta = inject(MetaInj, ref())
const { $api } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const IsPublic = inject(IsPublicInj, ref(false))
const { loadViewColumns } = useViewColumnsOrThrow()
const { loadCalendarMeta, loadCalendarData, loadSidebarData } = useCalendarViewStoreOrThrow()
const calendarRangeDropdown = ref(false)
watch(
() => activeView.value?.id,
async (newVal, oldVal) => {
if (newVal !== oldVal && meta.value) {
await loadViewColumns()
}
},
{ immediate: true },
)
const calendarRange = computed<CalendarRangeType[]>(() => {
const tempCalendarRange: CalendarRangeType[] = []
if (!activeView.value || !activeView.value.view) return tempCalendarRange
activeView.value.view.calendar_range?.forEach((range) => {
tempCalendarRange.push({
fk_from_column_id: range.fk_from_column_id,
fk_to_column_id: range.fk_to_column_id,
})
})
return tempCalendarRange
})
// We keep the calendar range here and update it when the user selects a new range
const _calendar_ranges = ref<CalendarRangeType[]>(calendarRange.value)
const saveCalendarRanges = async () => {
if (activeView.value) {
try {
const calRanges = _calendar_ranges.value
.filter((range) => range.fk_from_column_id)
.map((range) => ({
fk_from_column_id: range.fk_from_column_id,
fk_to_column_id: range.fk_to_column_id,
}))
await $api.dbView.calendarUpdate(activeView.value?.id as string, {
calendar_range: calRanges as CalendarRangeType[],
})
await loadCalendarMeta()
await Promise.all([loadCalendarData(), loadSidebarData()])
} catch (e) {
console.log(e)
message.error('There was an error while updating view!')
}
} else {
message.error('Please select a view first')
}
}
const dateFieldOptions = computed<SelectProps['options']>(() => {
return (
meta.value?.columns
?.filter((c) => c.uidt === UITypes.Date || (c.uidt === UITypes.DateTime && !isSystemColumn(c)))
.map((c) => ({
label: c.title,
value: c.id,
uidt: c.uidt,
})) ?? []
)
})
// To add new calendar range
const addCalendarRange = async () => {
_calendar_ranges.value.push({
fk_from_column_id: dateFieldOptions.value![0].value as string,
fk_to_column_id: null,
})
await saveCalendarRanges()
}
const removeRange = async (id: number) => {
_calendar_ranges.value = _calendar_ranges.value.filter((_, i) => i !== id)
await saveCalendarRanges()
}
const saveCalendarRange = async (range: CalendarRangeType, value?) => {
range.fk_to_column_id = value
await saveCalendarRanges()
}
</script>
<template>
<NcDropdown v-if="!IsPublic" v-model:visible="calendarRangeDropdown" :trigger="['click']" class="!xs:hidden">
<div class="nc-calendar-btn">
<a-button
v-e="['c:calendar:change-calendar-range']"
:disabled="isLocked"
class="nc-toolbar-btn"
data-testid="nc-calendar-range-btn"
>
<div class="flex items-center gap-2">
<component :is="iconMap.calendar" class="h-4 w-4" />
<span class="text-capitalize !text-sm font-medium">
{{ $t('activity.viewSettings') }}
</span>
</div>
</a-button>
</div>
<template #overlay>
<div v-if="calendarRangeDropdown" class="w-full p-6 w-[22rem]" data-testid="nc-calendar-range-menu" @click.stop>
<div
v-for="(range, id) in _calendar_ranges"
:key="id"
class="flex w-full gap-2 mb-2 items-center"
data-testid="nc-calendar-range-option"
>
<span>
{{ $t('labels.organiseBy') }}
</span>
<NcSelect
v-model:value="range.fk_from_column_id"
:placeholder="$t('placeholder.notSelected')"
data-testid="nc-calendar-range-from-field-select"
@change="saveCalendarRanges"
>
<a-select-option
v-for="(option, opId) in [...dateFieldOptions].filter((r) => {
if (id === 0) return true
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id)
return firstRange?.uidt === r.uidt
})"
:key="opId"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
<div
v-if="range.fk_to_column_id === null && isEeUI && false"
class="flex cursor-pointer flex text-gray-800 items-center gap-1"
data-testid="nc-calendar-range-add-end-date"
@click="saveCalendarRange(range, undefined)"
>
<component :is="iconMap.plus" class="h-4 w-4" />
{{ $t('activity.addEndDate') }}
</div>
<template v-else-if="isEeUI && false">
<span>
{{ $t('activity.withEndDate') }}
</span>
<div class="flex">
<NcSelect
v-model:value="range.fk_to_column_id"
:disabled="!range.fk_from_column_id"
:placeholder="$t('placeholder.notSelected')"
class="!rounded-r-none nc-to-select"
data-testid="nc-calendar-range-to-field-select"
@change="saveCalendarRanges"
>
<a-select-option
v-for="(option, opId) in [...dateFieldOptions].filter((f) => {
const firstRange = dateFieldOptions.find((f) => f.value === calendarRange[0].fk_from_column_id)
return firstRange?.uidt === f.uidt
})"
:key="opId"
:value="option.value"
>
<div class="flex items-center">
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
<template #title>{{ option.label }}</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
<NcButton class="!rounded-l-none !border-l-0" size="small" type="secondary" @click="saveCalendarRange(range, null)">
<component :is="iconMap.delete" class="h-4 w-4" />
</NcButton>
</div>
</template>
<NcButton v-if="id !== 0" size="small" type="secondary" @click="removeRange(id)">
<component :is="iconMap.close" />
</NcButton>
</div>
<NcButton
v-if="false"
class="mt-2"
data-testid="nc-calendar-range-add-btn"
size="small"
type="secondary"
@click="addCalendarRange"
>
<component :is="iconMap.plus" />
Add another date field
</NcButton>
</div>
</template>
</NcDropdown>
</template>
<style lang="scss" scoped>
.nc-to-select .ant-select-selector {
@apply !rounded-r-none;
}
</style>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType, GalleryType, KanbanType } from 'nocodb-sdk'
<script lang="ts" setup>
import type { CalendarType, ColumnType, GalleryType, KanbanType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
@ -76,7 +76,7 @@ watch(
const numberOfHiddenFields = computed(() => filteredFieldList.value?.filter((field) => !field.show)?.length)
const gridDisplayValueField = computed(() => {
if (activeView.value?.type !== ViewTypes.GRID) return null
if (activeView.value?.type !== ViewTypes.GRID && activeView.value?.type !== ViewTypes.CALENDAR) return null
const pvCol = Object.values(metaColumnById.value)?.find((col) => col?.pv)
return filteredFieldList.value?.find((field) => field.fk_column_id === pvCol?.id)
})
@ -151,7 +151,9 @@ const coverOptions = computed<SelectProps['options']>(() => {
const updateCoverImage = async (val?: string | null) => {
if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
(activeView.value?.type === ViewTypes.GALLERY ||
activeView.value?.type === ViewTypes.KANBAN ||
activeView.value?.type === ViewTypes.CALENDAR) &&
activeView.value?.id &&
activeView.value?.view
) {
@ -165,6 +167,11 @@ const updateCoverImage = async (val?: string | null) => {
fk_cover_image_col_id: val,
})
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.CALENDAR) {
await $api.dbView.calendarUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as CalendarType).fk_cover_image_col_id = val
}
reloadViewMetaHook?.trigger()
}
@ -173,7 +180,10 @@ const updateCoverImage = async (val?: string | null) => {
const coverImageColumnId = computed({
get: () => {
const fk_cover_image_col_id =
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && activeView.value?.view
(activeView.value?.type === ViewTypes.GALLERY ||
activeView.value?.type === ViewTypes.KANBAN ||
activeView.value?.type === ViewTypes.CALENDAR) &&
activeView.value?.view
? (activeView.value?.view as GalleryType).fk_cover_image_col_id
: undefined
// check if `fk_cover_image_col_id` is in `coverOptions`
@ -290,16 +300,16 @@ useMenuCloseOnEsc(open)
<NcDropdown
v-model:visible="open"
:trigger="['click']"
overlay-class-name="nc-dropdown-fields-menu nc-toolbar-dropdown"
class="!xs:hidden"
overlay-class-name="nc-dropdown-fields-menu nc-toolbar-dropdown"
>
<div :class="{ 'nc-active-btn': numberOfHiddenFields }">
<a-button v-e="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked">
<a-button v-e="['c:fields']" :disabled="isLocked" class="nc-fields-menu-btn nc-toolbar-btn">
<div class="flex items-center gap-2">
<GeneralIcon
v-if="activeView?.type === ViewTypes.KANBAN || activeView?.type === ViewTypes.GALLERY"
icon="creditCard"
class="h-4 w-4"
icon="creditCard"
/>
<component :is="iconMap.fields" v-else class="h-4 w-4" />
@ -328,18 +338,18 @@ useMenuCloseOnEsc(open)
<div class="flex text-sm select-none">Select cover image field</div>
<a-select
v-model:value="coverImageColumnId"
class="w-full"
:options="coverOptions"
class="w-full"
dropdown-class-name="nc-dropdown-cover-image"
@click.stop
>
<template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template>
<template #suffixIcon><GeneralIcon class="text-gray-700" icon="arrowDown" /></template>
</a-select>
</div>
<div class="pr-4" @click.stop>
<a-input v-model:value="filterQuery" :placeholder="$t('placeholder.searchFields')" class="!rounded-lg">
<template #prefix> <img src="~/assets/nc-icons/search.svg" class="h-3.5 w-3.5 mr-1" /> </template
<template #prefix> <img class="h-3.5 w-3.5 mr-1" src="~/assets/nc-icons/search.svg" /> </template
></a-input>
</div>
@ -360,12 +370,12 @@ useMenuCloseOnEsc(open)
<div
v-if="
filteredFieldList
.filter((el) => el !== gridDisplayValueField && el.title.toLowerCase().includes(filterQuery.toLowerCase()))
.filter((el) => (activeView.type !== ViewTypes.CALENDAR ? el !== gridDisplayValueField : true))
.includes(field)
"
:key="field.id"
class="px-2 py-2 flex flex-row items-center first:border-t-1 border-b-1 border-x-1 first:rounded-t-lg last:rounded-b-lg border-gray-200"
:data-testid="`nc-fields-menu-${field.title}`"
class="px-2 py-2 flex flex-row items-center first:border-t-1 border-b-1 border-x-1 first:rounded-t-lg last:rounded-b-lg border-gray-200"
@click.stop
>
<component :is="iconMap.drag" class="cursor-move !h-3.75 text-gray-600 mr-1" />
@ -380,7 +390,7 @@ useMenuCloseOnEsc(open)
"
>
<component :is="getIcon(metaColumnById[field.fk_column_id])" />
<NcTooltip show-on-truncate-only class="flex-1 px-1 truncate">
<NcTooltip class="flex-1 px-1 truncate" show-on-truncate-only>
<template #title>
{{ field.title }}
</template>
@ -397,16 +407,16 @@ useMenuCloseOnEsc(open)
<div
v-if="gridDisplayValueField && filteredFieldList[0].title.toLowerCase().includes(filterQuery.toLowerCase())"
:key="`pv-${gridDisplayValueField.id}`"
class="pl-7.4 pr-2 py-2 flex flex-row items-center border-1 border-gray-200"
:class="{
'rounded-t-lg': filteredFieldList.length > 1,
'rounded-lg': filteredFieldList.length === 1,
}"
:data-testid="`nc-fields-menu-${gridDisplayValueField.title}`"
class="pl-7.4 pr-2 py-2 flex flex-row items-center border-1 border-gray-200"
@click.stop
>
<component :is="getIcon(metaColumnById[filteredFieldList[0].fk_column_id as string])" />
<NcTooltip show-on-truncate-only class="px-1 flex-1 truncate">
<NcTooltip class="px-1 flex-1 truncate" show-on-truncate-only>
<template #title>{{ filteredFieldList[0].title }}</template>
<template #default>{{ filteredFieldList[0].title }}</template>
</NcTooltip>
@ -420,18 +430,18 @@ useMenuCloseOnEsc(open)
<div class="flex pr-4 mt-1 gap-2">
<NcButton
v-if="!filterQuery"
type="ghost"
size="sm"
class="nc-fields-show-all-fields !text-gray-500 !w-1/2"
size="sm"
type="ghost"
@click="showAllColumns = !showAllColumns"
>
{{ showAllColumns ? $t('title.hideAll') : $t('general.showAll') }} {{ $t('objects.fields').toLowerCase() }}
</NcButton>
<NcButton
v-if="!isPublic && !filterQuery"
type="ghost"
size="sm"
class="nc-fields-show-system-fields !text-gray-500 !w-1/2"
size="sm"
type="ghost"
@click="showSystemField = !showSystemField"
>
{{ showSystemField ? $t('title.hideSystemFields') : $t('activity.showSystemFields') }}
@ -442,7 +452,7 @@ useMenuCloseOnEsc(open)
</NcDropdown>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
// :deep(.ant-checkbox-inner) {
// @apply transform scale-60;
// }

21
packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue

@ -97,6 +97,7 @@ function onDuplicate() {
'selectedViewId': view.value!.id,
'groupingFieldColumnId': view.value!.view!.fk_grp_col_id,
'views': views,
'calendarRange': view.value!.view!.calendar_range,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
@ -141,9 +142,9 @@ const onDelete = async () => {
<template>
<NcMenu
v-if="view"
:data-testid="`view-sidebar-view-actions-${view!.alias || view!.title}`"
class="!min-w-70"
data-id="toolbar-actions"
:data-testid="`view-sidebar-view-actions-${view!.alias || view!.title}`"
>
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyViewID') }} </template>
@ -155,9 +156,9 @@ const onDelete = async () => {
})
}}
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isViewIdCopied" icon="check" class="max-h-4 min-w-4" />
<GeneralIcon v-else else icon="copy" class="max-h-4 min-w-4" />
<NcButton class="!group-hover:bg-gray-100" size="xsmall" type="secondary">
<GeneralIcon v-if="isViewIdCopied" class="max-h-4 min-w-4" icon="check" />
<GeneralIcon v-else class="max-h-4 min-w-4" else icon="copy" />
</NcButton>
</div>
</NcTooltip>
@ -176,7 +177,7 @@ const onDelete = async () => {
</NcMenuItem>
</NcTooltip>
<NcMenuItem @click="onDuplicate">
<GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
{{ $t('labels.duplicateView') }}
</NcMenuItem>
</template>
@ -212,8 +213,8 @@ const onDelete = async () => {
sidebar: props.inSidebar,
},
]"
class="nc-base-menu-item"
:class="{ disabled: lockType === LockType.Locked }"
class="nc-base-menu-item"
>
<component :is="iconMap.cloudUpload" />
{{ `${$t('general.upload')} ${type.toUpperCase()}` }}
@ -263,9 +264,9 @@ const onDelete = async () => {
</div>
<div class="nc-base-menu-item flex !flex-shrink group !py-1 !px-1 rounded-md bg-brand-50">
<LazySmartsheetToolbarLockType
hide-tick
:type="lockType"
class="flex nc-view-actions-lock-type !text-brand-500 !flex-shrink"
hide-tick
/>
</div>
<div class="flex flex-grow"></div>
@ -289,7 +290,7 @@ const onDelete = async () => {
<NcTooltip v-if="lockType === LockType.Locked">
<template #title> {{ $t('msg.info.disabledAsViewLocked') }} </template>
<NcMenuItem class="!cursor-not-allowed !text-gray-400">
<GeneralIcon icon="delete" class="nc-view-delete-icon" />
<GeneralIcon class="nc-view-delete-icon" icon="delete" />
{{
$t('general.deleteEntity', {
entity: $t('objects.view'),
@ -298,7 +299,7 @@ const onDelete = async () => {
</NcMenuItem>
</NcTooltip>
<NcMenuItem v-else class="!hover:bg-red-50 !text-red-500" @click="onDelete">
<GeneralIcon icon="delete" class="nc-view-delete-icon" />
<GeneralIcon class="nc-view-delete-icon" icon="delete" />
{{
$t('general.deleteEntity', {
entity: $t('objects.view'),
@ -311,9 +312,9 @@ const onDelete = async () => {
v-for="tp in quickImportDialogTypes"
:key="tp"
v-model="quickImportDialogs[tp].value"
:import-data-only="true"
:import-type="tp"
:source-id="currentBaseId"
:import-data-only="true"
/>
</template>
</NcMenu>

8
packages/nc-gui/components/tabs/Smartsheet.vue

@ -2,6 +2,7 @@
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { TabItem } from '#imports'
import {
ActiveViewInj,
FieldsInj,
@ -19,12 +20,12 @@ import {
ref,
toRef,
useMetas,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
useProvideSmartsheetStore,
useRoles,
useSqlEditor,
} from '#imports'
import type { TabItem } from '#imports'
const props = defineProps<{
activeTab: TabItem
@ -51,7 +52,7 @@ const { handleSidebarOpenOnMobileForNonViews } = useConfigStore()
const { activeTableId } = storeToRefs(useTablesStore())
const { activeView, openedViewsTab, activeViewTitleOrId } = storeToRefs(useViewsStore())
const { isGallery, isGrid, isForm, isKanban, isLocked, isMap } = useProvideSmartsheetStore(activeView, meta)
const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar } = useProvideSmartsheetStore(activeView, meta)
useSqlEditor()
@ -63,6 +64,7 @@ const openNewRecordFormHook = createEventHook<void>()
useProvideKanbanViewStore(meta, activeView)
useProvideMapViewStore(meta, activeView)
useProvideCalendarViewStore(meta, activeView)
// todo: move to store
provide(MetaInj, meta)
@ -182,6 +184,8 @@ watch([activeViewTitleOrId, activeTableId], () => {
<LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetCalendar v-else-if="isCalendar" />
<LazySmartsheetMap v-else-if="isMap" />
</template>
</div>

879
packages/nc-gui/composables/useCalendarViewStore.ts

@ -0,0 +1,879 @@
import type { ComputedRef, Ref } from 'vue'
import {
type Api,
type CalendarType,
type ColumnType,
type PaginatedType,
type TableType,
UITypes,
type ViewType,
} from 'nocodb-sdk'
import dayjs from 'dayjs'
import { extractPkFromRow, extractSdkResponseErrorMsg, rowPkData } from '~/utils'
import { IsPublicInj, type Row, ref, storeToRefs, useBase, useInjectionState, useUndoRedo } from '#imports'
const formatData = (list: Record<string, any>[]) =>
list.map(
(row) =>
({
row: { ...row },
oldRow: { ...row },
rowMeta: {},
} as Row),
)
const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
(
meta: Ref<((CalendarType & { id: string }) | TableType) | undefined>,
viewMeta: Ref<(ViewType | CalendarType | undefined) & { id: string }> | ComputedRef<(ViewType & { id: string }) | undefined>,
shared = false,
where?: ComputedRef<string | undefined>,
) => {
if (!meta) {
throw new Error('Table meta is not available')
}
const pageDate = ref<dayjs.Dayjs>(dayjs())
const { isUIAllowed } = useRoles()
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv))
const activeCalendarView = ref<'month' | 'year' | 'day' | 'week'>()
const searchQuery = reactive({
value: '',
field: '',
})
const selectedDate = ref<dayjs.Dayjs>(dayjs())
const selectedTime = ref<dayjs.Dayjs>(dayjs())
const selectedMonth = ref<dayjs.Dayjs>(dayjs())
const isCalendarDataLoading = ref<boolean>(false)
const showSideMenu = ref(true)
const selectedDateRange = ref<{
start: dayjs.Dayjs
end: dayjs.Dayjs
}>({
start: dayjs(selectedDate.value).startOf('week'), // This will be the previous Monday
end: dayjs(selectedDate.value).startOf('week').add(6, 'day'), // This will be the following Sunday
})
const defaultPageSize = 25
const formattedData = ref<Row[]>([])
const formattedSideBarData = ref<Row[]>([])
const isSidebarLoading = ref<boolean>(false)
const activeDates = ref<dayjs.Dayjs[]>([])
const sideBarFilterOption = ref<string>(activeCalendarView.value ?? 'allRecords')
const { api } = useApi()
const { base } = storeToRefs(useBase())
const { $api } = useNuxtApp()
const { t } = useI18n()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const isPublic = ref(shared) || inject(IsPublicInj, ref(false))
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { sharedView, fetchSharedViewData, fetchSharedViewActiveDate } = useSharedView()
const calendarMetaData = ref<CalendarType>({})
const paginationData = ref<PaginatedType>({ page: 1, pageSize: defaultPageSize })
const queryParams = computed(() => ({
limit: paginationData.value.pageSize ?? defaultPageSize,
where: where?.value ?? '',
}))
const calendarRange = ref<
Array<{
fk_from_col: ColumnType | null
fk_to_col: ColumnType | null
}>
>([])
const calDataType = computed(() => {
if (!calendarRange.value || !calendarRange.value[0]) return null
return calendarRange.value[0]!.fk_from_col!.uidt
})
const sideBarFilter = computed(() => {
let combinedFilters: any = []
if (sideBarFilterOption.value === 'allRecords') {
combinedFilters = []
} else if (sideBarFilterOption.value === 'withoutDates') {
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
if (!fromCol) return
const filters = []
if (fromCol) {
filters.push({
fk_column_id: fromCol.id,
logical_op: 'or',
comparison_op: 'blank',
})
}
if (toCol) {
filters.push({
fk_column_id: toCol.id,
logical_op: 'or',
comparison_op: 'blank',
})
}
// Combine the filters for this range with the overall filter array
combinedFilters = [...combinedFilters, ...filters]
})
// Wrap the combined filters in a group
combinedFilters = [
{
is_group: true,
logical_op: 'or',
children: combinedFilters,
},
]
} else if (
sideBarFilterOption.value === 'week' ||
sideBarFilterOption.value === 'month' ||
sideBarFilterOption.value === 'day' ||
sideBarFilterOption.value === 'year' ||
sideBarFilterOption.value === 'selectedDate' ||
sideBarFilterOption.value === 'selectedHours'
) {
let prevDate: string | null | dayjs.Dayjs = null
let fromDate: string | null | dayjs.Dayjs = null
let toDate: string | null | dayjs.Dayjs = null
let nextDate: string | null | dayjs.Dayjs = null
switch (sideBarFilterOption.value) {
case 'day':
fromDate = selectedDate.value.startOf('day')
toDate = selectedDate.value.endOf('day')
prevDate = selectedDate.value.subtract(1, 'day').endOf('day')
nextDate = selectedDate.value.add(1, 'day').startOf('day')
break
case 'week':
fromDate = selectedDateRange.value.start.startOf('day')
toDate = selectedDateRange.value.end.endOf('day')
prevDate = selectedDateRange.value.start.subtract(1, 'day').endOf('day')
nextDate = selectedDateRange.value.end.add(1, 'day').startOf('day')
break
case 'month': {
const startOfMonth = selectedMonth.value.startOf('month')
const endOfMonth = selectedMonth.value.endOf('month')
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35)
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day').add(1, 'day')
toDate = fromDate.add(daysToDisplay, 'day').endOf('day')
prevDate = fromDate.subtract(1, 'day').endOf('day')
nextDate = toDate.add(1, 'day').startOf('day')
break
}
case 'year':
fromDate = selectedDate.value.startOf('year')
toDate = selectedDate.value.endOf('year')
prevDate = fromDate.subtract(1, 'day').endOf('day')
nextDate = toDate.add(1, 'day').startOf('day')
break
case 'selectedDate':
fromDate = selectedDate.value.startOf('day')
toDate = selectedDate.value.endOf('day')
prevDate = selectedDate.value.subtract(1, 'day').endOf('day')
nextDate = selectedDate.value.add(1, 'day').startOf('day')
break
case 'selectedHours':
fromDate = (selectedTime.value ?? dayjs()).startOf('hour')
toDate = (selectedTime.value ?? dayjs()).endOf('hour')
prevDate = fromDate?.subtract(1, 'hour').endOf('hour')
nextDate = toDate?.add(1, 'hour').startOf('hour')
break
}
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ')
toDate = toDate!.format('YYYY-MM-DD HH:mm:ssZ')
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ')
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ')
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
let rangeFilter: any = []
if (fromCol && toCol) {
rangeFilter = [
{
is_group: true,
logical_op: 'and',
children: [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: toCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
],
},
{
fk_column_id: fromCol.id,
comparison_op: 'eq',
logical_op: 'or',
comparison_sub_op: 'exactDate',
value: fromDate,
},
]
} else if (fromCol) {
rangeFilter = [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: fromCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
]
}
if (rangeFilter.length > 0) {
combinedFilters.push({
is_group: true,
logical_op: 'or',
children: rangeFilter,
})
}
})
}
if (displayField.value && searchQuery.value) {
if (combinedFilters.length > 0) {
combinedFilters = [
{
is_group: true,
logical_op: 'and',
children: [
...combinedFilters,
{
fk_column_id: displayField.value.id,
comparison_op: 'like',
value: searchQuery.value,
},
],
},
]
} else {
combinedFilters.push({
fk_column_id: displayField.value.id,
comparison_op: 'like',
value: searchQuery.value,
})
}
}
return combinedFilters
})
async function loadMoreSidebarData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
if (isSidebarLoading.value) return
try {
const response = !isPublic.value
? await api.dbViewRow.list('noco', base.value.id!, meta.value!.id!, viewMeta.value!.id, {
...params,
offset: params.offset,
...{},
...{},
...(isUIAllowed('filterSync')
? { filterArrJson: JSON.stringify([...sideBarFilter.value]) }
: { filterArrJson: JSON.stringify([nestedFilters.value, ...sideBarFilter.value]) }),
})
: await fetchSharedViewData({
...params,
sortsArr: sorts.value,
filtersArr: sideBarFilter.value,
offset: params.offset,
where: where?.value ?? '',
})
formattedSideBarData.value = [...formattedSideBarData.value, ...formatData(response!.list)]
} catch (e) {
console.log(e)
}
}
const filterJSON = computed(() => {
const combinedFilters: any = {
is_group: true,
logical_op: 'and',
children: [],
}
let prevDate: string | null | dayjs.Dayjs = null
let fromDate: dayjs.Dayjs | null | string = null
let toDate: dayjs.Dayjs | null | string = null
let nextDate: string | null | dayjs.Dayjs = null
switch (activeCalendarView.value) {
case 'week':
fromDate = selectedDateRange.value.start.startOf('day')
toDate = selectedDateRange.value.end.endOf('day')
prevDate = selectedDateRange.value.start.subtract(1, 'day').endOf('day')
nextDate = selectedDateRange.value.end.add(1, 'day').startOf('day')
break
case 'month': {
const startOfMonth = selectedMonth.value.startOf('month')
const endOfMonth = selectedMonth.value.endOf('month')
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35)
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day')
toDate = fromDate.add(daysToDisplay, 'day')
prevDate = fromDate.subtract(1, 'day').endOf('day')
nextDate = toDate.add(1, 'day').startOf('day')
break
}
case 'year':
fromDate = selectedDate.value.startOf('year')
toDate = selectedDate.value.endOf('year')
prevDate = fromDate.subtract(1, 'day').endOf('day')
nextDate = toDate.add(1, 'day').startOf('day')
break
case 'day':
fromDate = selectedDate.value.startOf('day')
toDate = selectedDate.value.endOf('day')
prevDate = selectedDate.value.subtract(1, 'day').endOf('day')
nextDate = selectedDate.value.add(1, 'day').startOf('day')
break
}
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ')
toDate = toDate!.format('YYYY-MM-DD HH:mm:ssZ')
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ')
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ')
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
let rangeFilter: any = []
if (fromCol && toCol) {
rangeFilter = [
{
is_group: true,
logical_op: 'and',
children: [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: toCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
],
},
{
fk_column_id: fromCol.id,
comparison_op: 'eq',
logical_op: 'or',
comparison_sub_op: 'exactDate',
value: fromDate,
},
]
} else if (fromCol) {
rangeFilter = [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: fromCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
]
}
if (rangeFilter.length > 0) {
combinedFilters.children.push(rangeFilter)
}
})
return combinedFilters.children.length > 0 ? [combinedFilters] : []
})
const fetchActiveDates = async () => {
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id || !calendarRange.value) return
let prevDate: dayjs.Dayjs | string | null = null
let nextDate: dayjs.Dayjs | string | null = null
let fromDate: dayjs.Dayjs | string | null = null
if (activeCalendarView.value === 'week' || activeCalendarView.value === 'day' || activeCalendarView.value === 'month') {
const startOfMonth = pageDate.value.startOf('month')
const endOfMonth = pageDate.value.endOf('month')
const daysToDisplay = Math.max(endOfMonth.diff(startOfMonth, 'day') + 1, 35)
fromDate = startOfMonth.subtract((startOfMonth.day() + 7) % 7, 'day')
const toDate = fromDate.add(daysToDisplay, 'day')
prevDate = fromDate.subtract(1, 'day').endOf('day')
nextDate = toDate.add(1, 'day').startOf('day')
} else if (activeCalendarView.value === 'year') {
fromDate = selectedDate.value.startOf('year')
prevDate = selectedDate.value.startOf('year').subtract(1, 'day').endOf('day')
nextDate = selectedDate.value.endOf('year').add(1, 'day').startOf('day')
}
prevDate = prevDate!.format('YYYY-MM-DD HH:mm:ssZ')
nextDate = nextDate!.format('YYYY-MM-DD HH:mm:ssZ')
fromDate = fromDate!.format('YYYY-MM-DD HH:mm:ssZ')
const activeDateFilter: Array<any> = []
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
let rangeFilter: any = []
if (fromCol && toCol) {
rangeFilter = [
{
is_group: true,
logical_op: 'and',
children: [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: toCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
],
},
{
fk_column_id: fromCol.id,
comparison_op: 'eq',
logical_op: 'or',
comparison_sub_op: 'exactDate',
value: fromDate,
},
]
} else if (fromCol) {
rangeFilter = [
{
fk_column_id: fromCol.id,
comparison_op: 'lt',
comparison_sub_op: 'exactDate',
value: nextDate,
},
{
fk_column_id: fromCol.id,
comparison_op: 'gt',
comparison_sub_op: 'exactDate',
value: prevDate,
},
]
}
activeDateFilter.push(rangeFilter)
})
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) return
const res = !isPublic.value
? await api.dbViewRow.calendarCount('noco', base.value.id!, meta.value!.id!, viewMeta.value.id, {
...queryParams.value,
...{},
...{},
...{ filterArrJson: JSON.stringify([...activeDateFilter]) },
})
: await fetchSharedViewActiveDate({
sortsArr: sorts.value,
filtersArr: activeDateFilter,
})
if (res) {
activeDates.value = res.map((dateObj: unknown) => dayjs(dateObj))
} else {
activeDates.value = []
}
}
const changeCalendarView = async (view: 'month' | 'year' | 'day' | 'week') => {
try {
activeCalendarView.value = view
await updateCalendarMeta({
meta: {
...(typeof calendarMetaData.value.meta === 'string'
? JSON.parse(calendarMetaData.value.meta)
: calendarMetaData.value.meta),
active_view: view,
},
})
if (activeCalendarView.value === 'week') {
selectedTime.value = null
}
} catch (e) {
message.error('Error changing calendar view')
console.log(e)
}
}
async function loadCalendarMeta() {
if (!viewMeta?.value?.id || !meta?.value?.columns) return
const res = isPublic.value ? (sharedView.value?.view as CalendarType) : await $api.dbView.calendarRead(viewMeta.value.id)
calendarMetaData.value = res
const calMeta = typeof res.meta === 'string' ? JSON.parse(res.meta) : res.meta
activeCalendarView.value = calMeta?.active_view
if (!activeCalendarView.value) activeCalendarView.value = 'month'
calendarRange.value = res?.calendar_range!.map((range: any) => {
return {
fk_from_col: meta.value?.columns!.find((col) => col.id === range.fk_from_column_id),
fk_to_col: range.fk_to_column_id ? meta.value?.columns!.find((col) => col.id === range.fk_to_column_id) : null,
}
}) as any
}
async function loadCalendarData() {
if ((!base?.value?.id || !meta.value?.id || !viewMeta.value?.id || !filterJSON.value) && !isPublic?.value) return
isCalendarDataLoading.value = true
const res = !isPublic.value
? await api.dbViewRow.list(
'noco',
base.value.id!,
meta.value!.id!,
viewMeta.value!.id!,
{
...queryParams.value,
...(isUIAllowed('filterSync')
? { filterArrJson: JSON.stringify([...filterJSON.value]) }
: { filterArrJson: JSON.stringify([nestedFilters.value, ...filterJSON.value]) }),
where: where?.value ?? '',
},
{
headers: {
'xc-ignore-pagination': true,
},
},
)
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: filterJSON.value })
formattedData.value = formatData(res!.list)
isCalendarDataLoading.value = false
}
async function updateCalendarMeta(updateObj: Partial<CalendarType>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit') || isPublic.value) return
try {
await $api.dbView.calendarUpdate(viewMeta.value.id, updateObj)
calendarMetaData.value = {
...calendarMetaData.value,
...updateObj,
}
} catch (e) {
message.error('Error updating changes')
console.log(e)
}
}
function findRowInState(rowData: Record<string, any>) {
const pk: Record<string, string> = rowPkData(rowData, meta?.value?.columns as ColumnType[])
for (const row of formattedData.value) {
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) {
return row
}
}
}
const paginateCalendarView = async (action: 'next' | 'prev') => {
switch (activeCalendarView.value) {
case 'month':
selectedMonth.value = action === 'next' ? selectedMonth.value.add(1, 'month') : selectedMonth.value.subtract(1, 'month')
pageDate.value = action === 'next' ? pageDate.value.add(1, 'month') : pageDate.value.subtract(1, 'month')
// selectedDate.value = action === 'next' ? addMonths(selectedDate.value, 1) : addMonths(selectedDate.value, -1)
if (pageDate.value.year() !== selectedDate.value.year()) {
pageDate.value = selectedDate.value
}
break
case 'year':
selectedDate.value = action === 'next' ? selectedDate.value.add(1, 'year') : selectedDate.value.subtract(1, 'year')
if (pageDate.value.year() !== selectedDate.value.year()) {
pageDate.value = selectedDate.value
}
break
case 'day':
selectedDate.value = action === 'next' ? selectedDate.value.add(1, 'day') : selectedDate.value.subtract(1, 'day')
selectedTime.value = selectedDate.value
if (pageDate.value.year() !== selectedDate.value.year()) {
pageDate.value = selectedDate.value
} else if (pageDate.value.month() !== selectedDate.value.month()) {
pageDate.value = selectedDate.value
}
break
case 'week':
selectedDateRange.value =
action === 'next'
? {
start: selectedDateRange.value.start.add(7, 'day'),
end: selectedDateRange.value.end.add(7, 'day'),
}
: {
start: selectedDateRange.value.start.subtract(7, 'day'),
end: selectedDateRange.value.end.subtract(7, 'day'),
}
if (pageDate.value.month() !== selectedDateRange.value.end.month()) {
pageDate.value = selectedDateRange.value.start
}
break
}
}
const loadSidebarData = async () => {
if (!base?.value?.id || !meta.value?.id || !viewMeta.value?.id) return
isSidebarLoading.value = true
const res = !isPublic.value
? await api.dbViewRow.list('noco', base.value.id!, meta.value!.id!, viewMeta.value.id, {
...queryParams.value,
...{},
...{},
...{ filterArrJson: JSON.stringify([...sideBarFilter.value]) },
})
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: sideBarFilter.value })
formattedSideBarData.value = formatData(res!.list)
isSidebarLoading.value = false
}
async function updateRowProperty(toUpdate: Row, property: string[], undo = false) {
try {
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[])
const updateObj = property.reduce(
(
acc: {
[x: string]: string
},
curr,
) => {
acc[curr] = toUpdate.row[curr]
return acc
},
{},
)
const updatedRowData = await $api.dbViewRow.update(
NOCO,
base?.value.id as string,
meta.value?.id as string,
viewMeta?.value?.id as string,
id,
updateObj,
{
query: { ignoreWebhook: !undo },
},
// todo:
// {
// query: { ignoreWebhook: !saved }
// }
)
if (!undo) {
addUndo({
redo: {
fn: async (toUpdate: Row, property: string[]) => {
const updatedRow = await updateRowProperty(toUpdate, property, true)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedRow)
}
Object.assign(row?.oldRow, updatedRow)
},
args: [clone(toUpdate), property],
},
undo: {
fn: async (toUpdate: Row, property: string[]) => {
const updatedData = await updateRowProperty(
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta },
property,
true,
)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedData)
}
Object.assign(row!.oldRow, updatedData)
},
args: [clone(toUpdate), property],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
}
await fetchActiveDates()
return updatedRowData
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
}
}
watch(selectedDate, async (value, oldValue) => {
if (activeCalendarView.value === 'month' || activeCalendarView.value === 'week') {
if (sideBarFilterOption.value === 'selectedDate') {
await loadSidebarData()
}
} else if (activeCalendarView.value === 'year') {
if (value.year() !== oldValue.year()) {
await Promise.all([loadCalendarData(), loadSidebarData(), await fetchActiveDates()])
} else if (sideBarFilterOption.value === 'selectedDate') {
await loadSidebarData()
}
} else {
await Promise.all([loadSidebarData(), loadCalendarData()])
}
if (activeCalendarView.value === 'year' && value.year() !== oldValue.year()) {
await fetchActiveDates()
}
})
watch(selectedTime, async () => {
if (calDataType.value !== UITypes.Date) {
await loadSidebarData()
}
})
watch(selectedMonth, async (value, oldValue) => {
if (activeCalendarView.value !== 'month') return
if (value.month() !== oldValue.month()) {
await Promise.all([loadCalendarData(), loadSidebarData(), fetchActiveDates()])
}
})
watch(selectedDateRange, async () => {
if (activeCalendarView.value !== 'week') return
await Promise.all([loadCalendarData(), loadSidebarData()])
})
watch(activeCalendarView, async (value, oldValue) => {
if (oldValue === 'week') {
pageDate.value = selectedDate.value
selectedDate.value = selectedDateRange.value.start
selectedMonth.value = selectedDateRange.value.start
selectedTime.value = selectedDateRange.value.start
} else if (oldValue === 'month') {
selectedDate.value = selectedMonth.value
pageDate.value = selectedDate.value
selectedTime.value = selectedDate.value
selectedDateRange.value = {
start: selectedDate.value.startOf('week'),
end: selectedDate.value.endOf('week'),
}
} else if (oldValue === 'day') {
pageDate.value = selectedDate.value
selectedTime.value = selectedDate.value
selectedMonth.value = selectedDate.value
selectedDateRange.value = {
start: selectedDate.value.startOf('week'),
end: selectedDate.value.endOf('week'),
}
} else if (oldValue === 'year') {
selectedMonth.value = selectedDate.value
selectedTime.value = selectedDate.value
pageDate.value = selectedDate.value
selectedDateRange.value = {
start: selectedDate.value.startOf('week'),
end: selectedDate.value.endOf('week'),
}
}
sideBarFilterOption.value = activeCalendarView.value ?? 'allRecords'
await Promise.all([loadCalendarData(), loadSidebarData(), fetchActiveDates()])
})
watch(sideBarFilterOption, async () => {
await loadSidebarData()
})
watch(searchQuery, async () => {
await loadSidebarData()
})
watch(pageDate, async () => {
if (activeCalendarView.value === 'year') return
await fetchActiveDates()
})
return {
formattedSideBarData,
loadMoreSidebarData,
loadSidebarData,
displayField,
sideBarFilterOption,
searchQuery,
activeDates,
isCalendarDataLoading,
changeCalendarView,
calDataType,
loadCalendarMeta,
calendarRange,
loadCalendarData,
formattedData,
isSidebarLoading,
showSideMenu,
selectedTime,
updateCalendarMeta,
calendarMetaData,
updateRowProperty,
activeCalendarView,
pageDate,
paginationData,
selectedDate,
selectedMonth,
selectedDateRange,
paginateCalendarView,
}
},
)
export { useProvideCalendarViewStore }
export function useCalendarViewStoreOrThrow() {
const calendarViewStore = useCalendarViewStore()
if (calendarViewStore == null) throw new Error('Please call `useProvideCalendarViewStore` on the appropriate parent component')
return calendarViewStore
}

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

@ -1,4 +1,5 @@
import type {
CalendarType,
ExportTypes,
FilterType,
KanbanType,
@ -42,7 +43,7 @@ export function useSharedView() {
const allowCSVDownload = useState<boolean>('allowCSVDownload', () => false)
const meta = useState<TableType | KanbanType | MapType | undefined>('meta', () => undefined)
const meta = useState<TableType | KanbanType | MapType | CalendarType | undefined>('meta', () => undefined)
const formColumns = computed(
() =>
@ -109,16 +110,21 @@ export function useSharedView() {
}
}
const fetchSharedViewData = async (param: {
sortsArr: SortType[]
filtersArr: FilterType[]
fields?: any[]
sort?: any[]
where?: string
/** Query params for nested data */
nested?: any
offset?: number
}) => {
const fetchSharedViewData = async (
param: {
sortsArr: SortType[]
filtersArr: FilterType[]
fields?: any[]
sort?: any[]
where?: string
/** Query params for nested data */
nested?: any
offset?: number
},
headers?: {
ignorePagination?: boolean
},
) => {
if (!sharedView.value)
return {
list: [],
@ -139,6 +145,34 @@ export function useSharedView() {
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
sortArrJson: JSON.stringify(param.sortsArr ?? sorts.value),
} as any,
{
headers: {
'xc-password': password.value,
'xc-ignore-pagination': headers?.ignorePagination ? 'true' : 'false',
},
},
)
}
const fetchSharedViewActiveDate = async (param: {
sortsArr: SortType[]
filtersArr: FilterType[]
sort?: any[]
where?: string
}) => {
if (!sharedView.value)
return {
list: [],
pageInfo: {},
}
return await $api.public.calendarCount(
sharedView.value.uuid!,
{
...param,
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
sortArrJson: JSON.stringify(param.sortsArr ?? sorts.value),
} as any,
{
headers: {
'xc-password': password.value,
@ -199,6 +233,7 @@ export function useSharedView() {
meta,
nestedFilters,
fetchSharedViewData,
fetchSharedViewActiveDate,
fetchSharedViewGroupedData,
paginationData,
sorts,

2
packages/nc-gui/composables/useSmartsheetStore.ts

@ -34,6 +34,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const isGrid = computed(() => view.value?.type === ViewTypes.GRID)
const isForm = computed(() => view.value?.type === ViewTypes.FORM)
const isGallery = computed(() => view.value?.type === ViewTypes.GALLERY)
const isCalendar = computed(() => view.value?.type === ViewTypes.CALENDAR)
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared)
@ -89,6 +90,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isGallery,
isKanban,
isMap,
isCalendar,
isSharedForm,
sorts,
nestedFilters,

2
packages/nc-gui/context/index.ts

@ -10,6 +10,7 @@ export const ColumnInj: InjectionKey<Ref<ColumnType>> = Symbol('column-injection
export const MetaInj: InjectionKey<ComputedRef<TableType> | Ref<TableType>> = Symbol('meta-injection')
export const TabMetaInj: InjectionKey<ComputedRef<TabItem> | Ref<TabItem>> = Symbol('tab-meta-injection')
export const IsFormInj: InjectionKey<Ref<boolean>> = Symbol('is-form-injection')
export const IsCalendarInj: InjectionKey<Ref<boolean>> = Symbol('is-calendar-injection')
export const IsSurveyFormInj: InjectionKey<Ref<boolean>> = Symbol('is-survey-form-injection')
export const IsGridInj: InjectionKey<Ref<boolean>> = Symbol('is-grid-injection')
export const IsGroupByInj: InjectionKey<Ref<boolean>> = Symbol('is-group-by-injection')
@ -53,5 +54,6 @@ export const TreeViewInj: InjectionKey<{
openRenameTableDialog: (table: TableType, rightClick: boolean) => void
contextMenuTarget: { type?: 'base' | 'base' | 'table' | 'main' | 'layout'; value?: any }
}> = Symbol('tree-view-functions-injection')
export const CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-injection')
export const JsonExpandInj: InjectionKey<Ref<boolean>> = Symbol('json-expand-injection')
export const AllFiltersInj: InjectionKey<Ref<Record<string, FilterType[]>>> = Symbol('all-filters-injection')

2
packages/nc-gui/lang/en.json

@ -1097,7 +1097,7 @@
"tooLargeFieldEntity": "The field is too large to be converted to {entity}",
"roleRequired": "Role required",
"warning": {
"calendarNoFields": "Calendar view requires a date or date time field to be setup. Try setting up a calendar view after adding a date/ date time field!",
"calendarNoFields": "Calendar view requires a date or date time field to be setup. Try setting up a calendar view after adding a date / date time field!",
"kanbanNoFields": "Kanban view requires a single select field to be setup. Try setting up a kanban view after adding a single select field!",
"mapNoFields": "Map view requires a geo data field to be setup. Try setting up a map view after adding a geo data field!",
"dbValid": "Please make sure database you are trying to connect is valid! This operation can cause schema loss!!",

15
packages/nc-gui/lib/types.ts

@ -63,9 +63,23 @@ interface Row {
saving?: boolean
// use in datetime picker component
isUpdatedFromCopyNPaste?: Record<string, boolean>
// Used in Calendar view
style?: Partial<CSSStyleDeclaration>
range?: {
fk_from_col: ColumnType
fk_to_col: ColumnType | null
}
id?: string
position?: string
dayIndex?: number
}
}
interface CalendarRangeType {
fk_from_column_id: string
fk_to_column_id: string | null
}
type RolePermissions = Omit<typeof rolePermissions, 'guest' | 'admin' | 'super'>
type GetKeys<T> = T extends Record<any, Record<infer Key, boolean>> ? Key : never
@ -211,4 +225,5 @@ export type {
SidebarTableNode,
UsersSortType,
CommandPaletteType,
CalendarRangeType,
}

34
packages/nc-gui/pages/index/[typeOrId]/calendar/[viewId]/index.vue

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
hasSidebar: false,
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewCalendar v-else />
</template>

6
packages/nc-gui/plugins/a.dayjs.ts

@ -6,6 +6,9 @@ import utc from 'dayjs/plugin/utc.js'
import weekday from 'dayjs/plugin/weekday.js'
import timezone from 'dayjs/plugin/timezone.js'
import updateLocale from 'dayjs/plugin/updateLocale'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import isBetween from 'dayjs/plugin/isBetween'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(() => {
@ -16,6 +19,9 @@ export default defineNuxtPlugin(() => {
extend(weekday)
extend(timezone)
extend(updateLocale)
extend(isSameOrBefore)
extend(isSameOrAfter)
extend(isBetween)
dayjs.updateLocale('en', {
weekStart: 1,
})

18
packages/nc-gui/utils/browserUtils.ts

@ -4,3 +4,21 @@ export const isDrawerExist = () => document.querySelector('.ant-drawer-open')
export const isDrawerOrModalExist = () => document.querySelector('.ant-modal.active, .ant-drawer-open')
export const isExpandedCellInputExist = () => document.querySelector('.expanded-cell-input')
export const cmdKActive = () => document.querySelector('.cmdk-modal-active')
export const getScrollbarWidth = () => {
const outer = document.createElement('div')
outer.style.visibility = 'hidden'
outer.style.width = '100px'
document.body.appendChild(outer)
const widthNoScroll = outer.offsetWidth
outer.style.overflow = 'scroll'
const inner = document.createElement('div')
inner.style.width = '100%'
outer.appendChild(inner)
const widthWithScroll = inner.offsetWidth
outer.parentNode.removeChild(outer)
return widthNoScroll - widthWithScroll
}

8
packages/nc-gui/utils/dataUtils.ts

@ -114,3 +114,11 @@ export const rowDefaultData = (columns: ColumnType[] = []) => {
return defaultData
}
export const isRowEmpty = (record: any, col: any) => {
if (!record || !col) return true
const val = record.row[col.title]
if (!val) return true
return Array.isArray(val) && val.length === 0
}

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

@ -20,3 +20,7 @@ export const generateUniqueTitle = <T extends Record<string, any> = Record<strin
return `${title}-${c}`
}
export const generateRandomNumber = () => {
return window.crypto.getRandomValues(new Uint8Array(10)).join('')
}

2
packages/nc-gui/utils/iconUtils.ts

@ -7,6 +7,7 @@ import MdiStar from '~icons/mdi/star'
import MdiStarOutline from '~icons/mdi/star-outline'
import MdiHeart from '~icons/mdi/heart'
import MdiHeartOutline from '~icons/mdi/heart-outline'
import LayoutSidebar from '~icons/tabler/layout-sidebar'
import MdiMoonFull from '~icons/mdi/moon-full'
import MdiMoonNew from '~icons/mdi/moon-new'
import MdiThumbUp from '~icons/mdi/thumb-up'
@ -363,6 +364,7 @@ export const iconMap = {
arrowLeft: Left,
arrowUp: Up,
layout: PhLayout,
sidebar: LayoutSidebar,
doubleRightArrow: h('span', { class: 'material-symbols', style: '-webkit-text-stroke: 0.5px' }, 'keyboard_double_arrow_right'),
doubleLeftArrow: h('span', { class: 'material-symbols', style: '-webkit-text-stroke: 0.5px' }, 'keyboard_double_arrow_left'),
sidebarMinimise: PhCaretDoubleLeftThin, // h('span', { class: 'material-symbols' }, 'left_panel_close'),

5
packages/nc-gui/utils/viewUtils.ts

@ -1,11 +1,11 @@
import { ViewTypes } from 'nocodb-sdk'
import type { Language } from '../lib'
import { iconMap } from './iconUtils'
import type { Language } from '~/lib'
export const viewIcons: Record<number | string, { icon: any; color: string }> = {
[ViewTypes.GRID]: { icon: iconMap.grid, color: '#36BFFF' },
[ViewTypes.FORM]: { icon: iconMap.form, color: '#7D26CD' },
calendar: { icon: iconMap.calendar, color: 'purple' },
[ViewTypes.CALENDAR]: { icon: iconMap.calendar, color: '#B33771' },
[ViewTypes.GALLERY]: { icon: iconMap.gallery, color: '#FC3AC6' },
[ViewTypes.MAP]: { icon: iconMap.map, color: 'blue' },
[ViewTypes.KANBAN]: { icon: iconMap.kanban, color: '#FF9052' },
@ -18,6 +18,7 @@ export const viewTypeAlias: Record<number, string> = {
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
[ViewTypes.CALENDAR]: 'calendar',
}
export const isRtlLang = (lang: keyof typeof Language) => ['fa', 'ar'].includes(lang)

4
packages/noco-docs/docs/140.account-settings/030.authentication/010.overview.md

@ -11,8 +11,8 @@ This section provides an overview about different mechanisms available for authe
This is the default form based authentication mechanism available in NocoDB. Users can sign up using email and password and then login using the same credentials.
# Single Sign On (SSO)
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
Please reach [**out to sales**](https://calendly.com/nocodb) for SSO access.
:::
SSO is a session and user authentication service that permits a user to use one set of login credentials to access multiple applications. The service authenticates the end user for all the applications the user has been given rights to and eliminates further prompts when the user switches applications during the same session.

4
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/010.okta.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Okta', 'SAML']
keywords: ['SSO', 'Okta', 'SAML', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/020.auth0.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Auth0', 'SAML']
keywords: ['SSO', 'Auth0', 'SAML', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/030.ping-identity.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Ping Identity', 'SAML']
keywords: ['SSO', 'Ping Identity', 'SAML', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/040.azure-ad.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Active Directory', 'SAML']
keywords: ['SSO', 'Active Directory', 'SAML', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::
This article briefs about the steps to configure Active Directory as Identity service provider for NocoDB

4
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/050.keycloak.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Keycloak', 'SAML']
keywords: ['SSO', 'Keycloak', 'SAML', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/010.okta.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Okta', 'OIDC']
keywords: ['SSO', 'Okta', 'OIDC', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
Please reach [**out to sales**](https://calendly.com/nocodb) for SSO access.
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/020.auth0.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Auth0', 'OIDC']
keywords: ['SSO', 'Auth0', 'OIDC', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/030.ping-identity.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Ping Identity', 'OIDC']
keywords: ['SSO', 'Ping Identity', 'OIDC', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
For SSO Access - please reach [**out to sales team**](https://calendly.com/nocodb).
:::

4
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/040.azure-ad.md

@ -5,8 +5,8 @@ tags: ['SSO', 'Azure AD', 'OIDC']
keywords: ['SSO', 'Azure AD', 'OIDC', 'Authentication', 'Identity Provider']
---
:::warning
SSO is available under private beta for self hosted enterprise customers. Please reach [**out to us**](https://calendly.com/nocodb) for early access.
:::info
Please reach [**out to sales**](https://calendly.com/nocodb) for SSO access.
:::
This article briefs about the steps to configure Azure AD as Identity service provider for NocoDB

1
packages/nocodb-sdk/src/lib/globals.ts

@ -6,6 +6,7 @@ export enum ViewTypes {
GRID = 3,
KANBAN = 4,
MAP = 5,
CALENDAR = 6,
}
export enum ProjectTypes {

21
packages/nocodb/src/controllers/calendars.controller.spec.ts

@ -0,0 +1,21 @@
import { Test } from '@nestjs/testing';
import { CalendarsService } from '../services/calendars.service';
import { CalendarsController } from './calendars.controller';
import type { TestingModule } from '@nestjs/testing';
describe('CalendarsController', () => {
let controller: CalendarsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CalendarsController],
providers: [CalendarsService],
}).compile();
controller = module.get<CalendarsController>(CalendarsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

70
packages/nocodb/src/controllers/calendars.controller.ts

@ -0,0 +1,70 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { CalendarsService } from '~/services/calendars.service';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class CalendarsController {
constructor(private readonly calendarsService: CalendarsService) {}
@Get([
'/api/v1/db/meta/calendars/:calendarViewId',
'/api/v2/meta/calendars/:calendarViewId',
])
@Acl('calendarViewGet')
async calendarViewGet(@Param('calendarViewId') calendarViewId: string) {
return await this.calendarsService.calendarViewGet({
calendarViewId,
});
}
@Post([
'/api/v1/db/meta/tables/:tableId/calendars',
'/api/v2/meta/tables/:tableId/calendars',
])
@HttpCode(200)
@Acl('calendarViewCreate')
async calendarViewCreate(
@Param('tableId') tableId: string,
@Body() body: ViewCreateReqType,
@Req() req: Request,
) {
return await this.calendarsService.calendarViewCreate({
tableId,
calendar: body,
user: req.user,
req,
});
}
@Patch([
'/api/v1/db/meta/calendars/:calendarViewId',
'/api/v2/meta/calendars/:calendarViewId',
])
@Acl('calendarViewUpdate')
async calendarViewUpdate(
@Param('calendarViewId') calendarViewId: string,
@Body() body,
@Req() req: Request,
) {
return await this.calendarsService.calendarViewUpdate({
calendarViewId,
calendar: body,
req,
});
}
}

25
packages/nocodb/src/controllers/data-alias.controller.ts

@ -45,6 +45,7 @@ export class DataAliasController {
tableName: tableName,
viewName: viewName,
disableOptimization: opt === 'false',
ignorePagination: req.headers?.['xc-ignore-pagination'] === 'true',
});
const elapsedMilliSeconds = parseHrtimeToMilliSeconds(
process.hrtime(startTime),
@ -117,6 +118,30 @@ export class DataAliasController {
res.json(countResult);
}
@Get([
'/api/v1/db/data/:orgs/:baseName/:tableName/countByDate/',
'/api/v1/db/data/:orgs/:baseName/:tableName/views/:viewName/countByDate/',
])
@Acl('dataList')
async calendarDataCount(
@Req() req: Request,
@Res() res: Response,
@Param('baseName') baseName: string,
@Param('tableName') tableName: string,
@Param('viewName') viewName: string,
) {
const startTime = process.hrtime();
const data = await this.datasService.getCalendarRecordCount({
query: req.query,
viewId: viewName,
});
const elapsedSeconds = parseHrtimeToMilliSeconds(process.hrtime(startTime));
res.setHeader('xc-db-response', elapsedSeconds);
res.json(data);
}
@Post([
'/api/v1/db/data/:orgs/:baseName/:tableName',
'/api/v1/db/data/:orgs/:baseName/:tableName/views/:viewName',

1
packages/nocodb/src/controllers/data-table.controller.ts

@ -38,6 +38,7 @@ export class DataTableController {
query: req.query,
modelId: modelId,
viewId: viewId,
ignorePagination: req.headers?.['xc-ignore-pagination'] === 'true',
});
const elapsedSeconds = parseHrtimeToMilliSeconds(process.hrtime(startTime));
res.setHeader('xc-db-response', elapsedSeconds);

15
packages/nocodb/src/controllers/public-datas.controller.ts

@ -34,6 +34,21 @@ export class PublicDatasController {
return pagedResponse;
}
@Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/countByDate',
'/api/v2/public/shared-view/:sharedViewUuid/countByDate',
])
async countByDate(
@Req() req: Request,
@Param('sharedViewUuid') sharedViewUuid: string,
) {
return await this.publicDatasService.getCalendarRecordCount({
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid,
});
}
@Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/groupby',
'/api/v2/public/shared-view/:sharedViewUuid/groupby',

158
packages/nocodb/src/db/conditionV2.ts

@ -725,7 +725,7 @@ const parseConditionV2 = async (
].includes(column.uidt)
) {
if (qb.client.config.client === 'pg') {
// todo: enbale back if group by date required custom implementation
// todo: enable back if group by date required custom implementation
// if ((filter as any).groupby)
// qb = qb.where(knex.raw('??::timestamp = ?', [field, val]));
// else
@ -899,11 +899,40 @@ const parseConditionV2 = async (
case 'gt':
{
const gt_op = customWhereClause ? '<' : '>';
qb = qb.where(field, gt_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (gt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
// If the column is a datetime and the client is pg and the value has a timezone offset at the end
// then we need to convert the value to timestamptz before comparing
if (
column.uidt === UITypes.DateTime &&
val.match(/[+-]\d{2}:\d{2}$/)
) {
if (qb.client.config.client === 'pg') {
qb.where(field, gt_op, knex.raw('?::timestamptz', [val]));
} else if (qb.client.config.client === 'sqlite3') {
qb.where(
field,
gt_op,
knex.raw('datetime(?)', [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else if (qb.client.config.client === 'mysql2') {
qb.where(
field,
gt_op,
knex.raw(`CONVERT_TZ(?, '+00:00', @@GLOBAL.time_zone)`, [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else {
qb.where(field, gt_op, val);
}
} else {
qb = qb.where(field, gt_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (gt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
}
}
}
}
@ -912,11 +941,40 @@ const parseConditionV2 = async (
case 'gte':
{
const ge_op = customWhereClause ? '<=' : '>=';
qb = qb.where(field, ge_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (ge_op === '<=' || (ge_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
// If the column is a datetime and the client is pg and the value has a timezone offset at the end
// then we need to convert the value to timestamptz before comparing
if (
column.uidt === UITypes.DateTime &&
val.match(/[+-]\d{2}:\d{2}$/)
) {
if (qb.client.config.client === 'pg') {
qb.where(field, ge_op, knex.raw('?::timestamptz', [val]));
} else if (qb.client.config.client === 'sqlite3') {
qb.where(
field,
ge_op,
knex.raw('datetime(?)', [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else if (qb.client.config.client === 'mysql2') {
qb.where(
field,
ge_op,
knex.raw(`CONVERT_TZ(?, '+00:00', @@GLOBAL.time_zone)`, [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else {
qb.where(field, ge_op, val);
}
} else {
qb = qb.where(field, ge_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (ge_op === '<=' || (ge_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
}
}
}
}
@ -924,11 +982,40 @@ const parseConditionV2 = async (
case 'lt':
{
const lt_op = customWhereClause ? '>' : '<';
qb = qb.where(field, lt_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (lt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
// If the column is a datetime and the client is pg and the value has a timezone offset at the end
// then we need to convert the value to timestamptz before comparing
if (
column.uidt === UITypes.DateTime &&
val.match(/[+-]\d{2}:\d{2}$/)
) {
if (qb.client.config.client === 'pg') {
qb.where(field, lt_op, knex.raw('?::timestamptz', [val]));
} else if (qb.client.config.client === 'sqlite3') {
qb.where(
field,
lt_op,
knex.raw('datetime(?)', [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else if (qb.client.config.client === 'mysql2') {
qb.where(
field,
lt_op,
knex.raw(`CONVERT_TZ(?, '+00:00', @@GLOBAL.time_zone)`, [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else {
qb.where(field, lt_op, val);
}
} else {
qb = qb.where(field, lt_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (lt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
}
}
}
}
@ -938,11 +1025,40 @@ const parseConditionV2 = async (
case 'lte':
{
const le_op = customWhereClause ? '>=' : '<=';
qb = qb.where(field, le_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (le_op === '<=' || (le_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
// If the column is a datetime and the client is pg and the value has a timezone offset at the end
// then we need to convert the value to timestamptz before comparing
if (
column.uidt === UITypes.DateTime &&
val.match(/[+-]\d{2}:\d{2}$/)
) {
if (qb.client.config.client === 'pg') {
qb.where(field, le_op, knex.raw('?::timestamptz', [val]));
} else if (qb.client.config.client === 'sqlite3') {
qb.where(
field,
le_op,
knex.raw('datetime(?)', [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else if (qb.client.config.client === 'mysql2') {
qb.where(
field,
le_op,
knex.raw(`CONVERT_TZ(?, '+00:00', @@GLOBAL.time_zone)`, [
dayjs(val).utc().format('YYYY-MM-DD HH:mm:ss'),
]),
);
} else {
qb.where(field, le_op, val);
}
} else {
qb = qb.where(field, le_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (le_op === '<=' || (le_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
}
}
}
}

42
packages/nocodb/src/helpers/getAst.ts

@ -13,7 +13,7 @@ import type {
Model,
} from '~/models';
import { NcError } from '~/helpers/catchError';
import { GalleryView, KanbanView, View } from '~/models';
import { CalendarRange, GalleryView, KanbanView, View } from '~/models';
const getAst = async ({
query,
@ -28,6 +28,7 @@ const getAst = async ({
},
getHiddenColumn = query?.['getHiddenColumn'],
throwErrorIfInvalidParams = false,
extractOnlyRangeFields = false,
}: {
query?: RequestQuery;
extractOnlyPrimaries?: boolean;
@ -37,18 +38,32 @@ const getAst = async ({
dependencyFields?: DependantFields;
getHiddenColumn?: boolean;
throwErrorIfInvalidParams?: boolean;
// Used for calendar view
extractOnlyRangeFields?: boolean;
}) => {
// set default values of dependencyFields and nested
dependencyFields.nested = dependencyFields.nested || {};
dependencyFields.fieldsSet = dependencyFields.fieldsSet || new Set();
let coverImageId;
let dependencyFieldsForCalenderView;
if (view && view.type === ViewTypes.GALLERY) {
const gallery = await GalleryView.get(view.id);
coverImageId = gallery.fk_cover_image_col_id;
} else if (view && view.type === ViewTypes.KANBAN) {
const kanban = await KanbanView.get(view.id);
coverImageId = kanban.fk_cover_image_col_id;
} else if (view && view.type === ViewTypes.CALENDAR) {
// const calendar = await CalendarView.get(view.id);
// coverImageId = calendar.fk_cover_image_col_id;
const calenderRanges = await CalendarRange.read(view.id);
if (calenderRanges) {
dependencyFieldsForCalenderView = calenderRanges.ranges
.flatMap((obj) =>
[obj.fk_from_column_id, (obj as any).fk_to_column_id].filter(Boolean),
)
.map(String);
}
}
if (!model.columns?.length) await model.getColumns();
@ -70,6 +85,26 @@ const getAst = async ({
return { ast, dependencyFields, parsedQuery: dependencyFields };
}
if (extractOnlyRangeFields) {
const ast = {
...(dependencyFieldsForCalenderView || []).reduce((o, f) => {
const col = model.columns.find((c) => c.id === f);
return { ...o, [col.title]: 1 };
}, {}),
};
await Promise.all(
(dependencyFieldsForCalenderView || []).map((f) =>
extractDependencies(
model.columns.find((c) => c.id === f),
dependencyFields,
),
),
);
return { ast, dependencyFields, parsedQuery: dependencyFields };
}
let fields = query?.fields || query?.f;
if (fields && fields !== '*') {
fields = Array.isArray(fields) ? fields : fields.split(',');
@ -99,6 +134,11 @@ const getAst = async ({
if (coverImageId) {
allowedCols[coverImageId] = 1;
}
if (dependencyFieldsForCalenderView) {
dependencyFieldsForCalenderView.forEach((id) => {
allowedCols[id] = 1;
});
}
}
const ast = await model.columns.reduce(async (obj, col: Column) => {

12
packages/nocodb/src/meta/meta.service.ts

@ -4,13 +4,14 @@ import CryptoJS from 'crypto-js';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import type { Knex } from 'knex';
import type * as knex from 'knex';
import type { Knex } from 'knex';
import XcMigrationSource from '~/meta/migrations/XcMigrationSource';
import XcMigrationSourcev2 from '~/meta/migrations/XcMigrationSourcev2';
import { XKnex } from '~/db/CustomKnex';
import { NcConfig } from '~/utils/nc-config';
import { MetaTable } from '~/utils/globals';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -215,6 +216,15 @@ export class MetaService {
case MetaTable.KANBAN_VIEW_COLUMNS:
prefix = 'kvc';
break;
case MetaTable.CALENDAR_VIEW:
prefix = 'cv';
break;
case MetaTable.CALENDAR_VIEW_COLUMNS:
prefix = 'cvc';
break;
case MetaTable.CALENDAR_VIEW_RANGE:
prefix = 'cvr';
break;
case MetaTable.USERS:
prefix = 'us';
break;

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -27,6 +27,7 @@ import * as nc_037_rename_project_and_base from '~/meta/migrations/v2/nc_037_ren
import * as nc_038_formula_parsed_tree_column from '~/meta/migrations/v2/nc_038_formula_parsed_tree_column';
import * as nc_039_sqlite_alter_column_types from '~/meta/migrations/v2/nc_039_sqlite_alter_column_types';
import * as nc_040_form_view_alter_column_types from '~/meta/migrations/v2/nc_040_form_view_alter_column_types';
import * as nc_041_calendar_view from '~/meta/migrations/v2/nc_041_calendar_view';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -65,6 +66,7 @@ export default class XcMigrationSourcev2 {
'nc_038_formula_parsed_tree_column',
'nc_039_sqlite_alter_column_types',
'nc_040_form_view_alter_column_types',
'nc_041_calendar_view',
]);
}
@ -132,6 +134,8 @@ export default class XcMigrationSourcev2 {
return nc_039_sqlite_alter_column_types;
case 'nc_040_form_view_alter_column_types':
return nc_040_form_view_alter_column_types;
case 'nc_041_calendar_view':
return nc_041_calendar_view;
}
}
}

66
packages/nocodb/src/meta/migrations/v2/nc_041_calendar_view.ts

@ -0,0 +1,66 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.CALENDAR_VIEW, (table) => {
table.string('fk_view_id', 20).primary();
table.string('base_id', 20);
table.string('source_id', 128);
table.string('title');
table.string('fk_cover_image_col_id', 20);
table.text('meta');
table.dateTime('created_at');
table.dateTime('updated_at');
});
await knex.schema.createTable(MetaTable.CALENDAR_VIEW_COLUMNS, (table) => {
table.string('id', 20).primary().notNullable();
table.string('base_id', 20);
table.string('source_id', 128);
table.string('fk_view_id', 20);
table.string('fk_column_id', 20);
table.boolean('show');
table.boolean('bold');
table.boolean('underline');
table.boolean('italic');
table.float('order');
table.timestamps(true, true);
});
await knex.schema.createTable(MetaTable.CALENDAR_VIEW_RANGE, (table) => {
table.string('id', 20).primary().notNullable();
table.string('fk_view_id', 20);
table.string('fk_to_column_id', 20);
table.string('label', 40);
table.string('fk_from_column_id', 20);
table.timestamps(true, true);
});
};
const down = async (knex: Knex) => {
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW);
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW_COLUMNS);
await knex.schema.dropTable(MetaTable.CALENDAR_VIEW_RANGE);
};
export { up, down };

6
packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts

@ -81,13 +81,15 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
params.formViewId ||
params.gridViewId ||
params.kanbanViewId ||
params.galleryViewId
params.galleryViewId ||
params.calendarViewId
) {
const view = await View.get(
params.formViewId ||
params.gridViewId ||
params.kanbanViewId ||
params.galleryViewId,
params.galleryViewId ||
params.calendarViewId,
);
req.ncBaseId = view?.base_id;
} else if (params.publicDataUuid) {

122
packages/nocodb/src/models/CalendarRange.ts

@ -0,0 +1,122 @@
import type { CalendarRangeType } from 'nocodb-sdk';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps';
import { CacheDelDirection, CacheScope, MetaTable } from '~/utils/globals';
export default class CalendarRange implements CalendarRangeType {
id?: string;
fk_from_column_id?: string;
fk_view_id?: string;
constructor(data: Partial<CalendarRange>) {
Object.assign(this, data);
}
public static async bulkInsert(
data: Partial<CalendarRange>[],
ncMeta = Noco.ncMeta,
) {
let insertObj = [];
for (const d of data) {
const tempObj = extractProps(d, ['fk_from_column_id', 'fk_view_id']);
insertObj.push(tempObj);
}
if (!insertObj.length) return false;
insertObj = insertObj[0];
const insertData = await ncMeta.metaInsert2(
null,
null,
MetaTable.CALENDAR_VIEW_RANGE,
insertObj,
);
await NocoCache.deepDel(
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.fk_view_id}:list`,
CacheDelDirection.PARENT_TO_CHILD,
);
await NocoCache.set(
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.id}`,
insertData,
);
await NocoCache.appendToList(
CacheScope.CALENDAR_VIEW_RANGE,
[insertData.fk_view_id],
`${CacheScope.CALENDAR_VIEW_RANGE}:${insertData.id}`,
);
return true;
}
public static async read(fk_view_id: string, ncMeta = Noco.ncMeta) {
const cachedList = await NocoCache.getList(CacheScope.CALENDAR_VIEW_RANGE, [
fk_view_id,
]);
let { list: ranges } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !ranges.length) {
ranges = await ncMeta.metaList2(
null, //,
null, //model.db_alias,
MetaTable.CALENDAR_VIEW_RANGE,
{ condition: { fk_view_id } },
);
await NocoCache.setList(
CacheScope.CALENDAR_VIEW_RANGE,
[fk_view_id],
ranges.map(({ created_at, updated_at, ...others }) => others),
);
}
return ranges?.length
? {
ranges: ranges.map(
({ created_at, updated_at, ...c }) => new CalendarRange(c),
),
}
: null;
}
public static async find(
fk_view_id: string,
ncMeta = Noco.ncMeta,
): Promise<CalendarRange> {
const data = await ncMeta.metaGet2(
null,
null,
MetaTable.CALENDAR_VIEW_RANGE,
{
fk_view_id,
},
);
return data && new CalendarRange(data);
}
public static async IsColumnBeingUsedAsRange(
columnId: string,
ncMeta = Noco.ncMeta,
) {
return (
(
await ncMeta.metaList2(null, null, MetaTable.CALENDAR_VIEW_RANGE, {
xcCondition: {
_or: [
{
fk_from_column_id: {
eq: columnId,
},
},
],
},
})
).length > 0
);
}
}

135
packages/nocodb/src/models/CalendarView.ts

@ -0,0 +1,135 @@
import type { BoolType, MetaType } from 'nocodb-sdk';
import type { CalendarType } from 'nocodb-sdk';
import View from '~/models/View';
import { extractProps } from '~/helpers/extractProps';
import NocoCache from '~/cache/NocoCache';
import Noco from '~/Noco';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import CalendarRange from '~/models/CalendarRange';
export default class CalendarView implements CalendarType {
fk_view_id: string;
title: string;
base_id?: string;
source_id?: string;
meta?: MetaType;
calendar_range?: Array<Partial<CalendarRange>>;
fk_cover_image_col_id?: string;
// below fields are not in use at this moment
// keep them for time being
show?: BoolType;
public?: BoolType;
password?: string;
show_all_fields?: BoolType;
constructor(data: CalendarView) {
Object.assign(this, data);
}
public static async get(viewId: string, ncMeta = Noco.ncMeta) {
let view =
viewId &&
(await NocoCache.get(
`${CacheScope.CALENDAR_VIEW}:${viewId}`,
CacheGetType.TYPE_OBJECT,
));
if (view) {
const calendarRange = await CalendarRange.read(viewId, ncMeta);
if (calendarRange) {
view.calendar_range = calendarRange.ranges;
} else {
view.calendar_range = [];
}
} else {
view = await ncMeta.metaGet2(null, null, MetaTable.CALENDAR_VIEW, {
fk_view_id: viewId,
});
const calendarRange = await CalendarRange.read(viewId);
if (view && calendarRange) {
view.calendar_range = calendarRange.ranges;
}
await NocoCache.set(`${CacheScope.CALENDAR_VIEW}:${viewId}`, view);
}
return view && new CalendarView(view);
}
static async insert(view: Partial<CalendarView>, ncMeta = Noco.ncMeta) {
const insertObj = {
base_id: view.base_id,
source_id: view.source_id,
fk_view_id: view.fk_view_id,
meta: view.meta,
};
const viewRef = await View.get(view.fk_view_id);
if (!(view.base_id && view.source_id)) {
insertObj.base_id = viewRef.base_id;
insertObj.source_id = viewRef.source_id;
}
await ncMeta.metaInsert2(
null,
null,
MetaTable.CALENDAR_VIEW,
insertObj,
true,
);
return this.get(view.fk_view_id, ncMeta);
}
static async update(
calendarId: string,
body: Partial<CalendarView>,
ncMeta = Noco.ncMeta,
) {
// get existing cache
const key = `${CacheScope.CALENDAR_VIEW}:${calendarId}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
const updateObj = extractProps(body, ['fk_cover_image_col_id', 'meta']);
if (updateObj.meta && typeof updateObj.meta === 'object') {
updateObj.meta = JSON.stringify(updateObj.meta ?? {});
}
if (o) {
o = { ...o, ...updateObj };
// set cache
await NocoCache.set(key, o);
}
if (body.calendar_range) {
await NocoCache.del(`${CacheScope.CALENDAR_VIEW}:${calendarId}`);
await ncMeta.metaDelete(
null,
null,
MetaTable.CALENDAR_VIEW_RANGE,
{},
{
fk_view_id: calendarId,
},
);
await CalendarRange.bulkInsert(
body.calendar_range.map((range) => {
return {
fk_view_id: calendarId,
...range,
};
}),
);
}
// update meta
return await ncMeta.metaUpdate(
null,
null,
MetaTable.CALENDAR_VIEW,
updateObj,
{
fk_view_id: calendarId,
},
);
}
}

182
packages/nocodb/src/models/CalendarViewColumn.ts

@ -0,0 +1,182 @@
import type { BoolType, MetaType } from 'nocodb-sdk';
import View from '~/models/View';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
import { extractProps } from '~/helpers/extractProps';
import { deserializeJSON } from '~/utils/serialize';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
export default class CalendarViewColumn {
id?: string;
fk_view_id?: string;
fk_column_id?: string;
base_id?: string;
source_id?: string;
show?: BoolType;
underline?: BoolType;
bold?: BoolType;
italic?: BoolType;
order?: number;
meta?: MetaType;
constructor(data: CalendarViewColumn) {
Object.assign(this, data);
}
public static async get(calendarViewColumnId: string, ncMeta = Noco.ncMeta) {
let viewColumn =
calendarViewColumnId &&
(await NocoCache.get(
`${CacheScope.CALENDAR_VIEW_COLUMN}:${calendarViewColumnId}`,
CacheGetType.TYPE_OBJECT,
));
if (!viewColumn) {
viewColumn = await ncMeta.metaGet2(
null,
null,
MetaTable.CALENDAR_VIEW_COLUMNS,
calendarViewColumnId,
);
viewColumn.meta =
viewColumn.meta && typeof viewColumn.meta === 'string'
? JSON.parse(viewColumn.meta)
: viewColumn.meta;
await NocoCache.set(
`${CacheScope.CALENDAR_VIEW_COLUMN}:${calendarViewColumnId}`,
viewColumn,
);
}
return viewColumn && new CalendarViewColumn(viewColumn);
}
static async insert(
column: Partial<CalendarViewColumn>,
ncMeta = Noco.ncMeta,
) {
const insertObj = extractProps(column, [
'fk_view_id',
'fk_column_id',
'show',
'base_id',
'source_id',
'underline',
'bold',
'italic',
]);
insertObj.order = await ncMeta.metaGetNextOrder(
MetaTable.CALENDAR_VIEW_COLUMNS,
{
fk_view_id: insertObj.fk_view_id,
},
);
if (!(insertObj.base_id && insertObj.source_id)) {
const viewRef = await View.get(insertObj.fk_view_id, ncMeta);
insertObj.base_id = viewRef.base_id;
insertObj.source_id = viewRef.source_id;
}
const { id, fk_column_id } = await ncMeta.metaInsert2(
null,
null,
MetaTable.CALENDAR_VIEW_COLUMNS,
insertObj,
);
await NocoCache.set(
`${CacheScope.CALENDAR_VIEW_COLUMN}:${fk_column_id}`,
id,
);
{
const view = await View.get(column.fk_view_id, ncMeta);
await View.clearSingleQueryCache(view.fk_model_id, [view]);
}
return this.get(id, ncMeta).then(async (viewColumn) => {
await NocoCache.appendToList(
CacheScope.CALENDAR_VIEW_COLUMN,
[column.fk_view_id],
`${CacheScope.CALENDAR_VIEW_COLUMN}:${id}`,
);
return viewColumn;
});
}
public static async list(
viewId: string,
ncMeta = Noco.ncMeta,
): Promise<CalendarViewColumn[]> {
const cachedList = await NocoCache.getList(
CacheScope.CALENDAR_VIEW_COLUMN,
[viewId],
);
let { list: viewColumns } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !viewColumns.length) {
viewColumns = await ncMeta.metaList2(
null,
null,
MetaTable.CALENDAR_VIEW_COLUMNS,
{
condition: {
fk_view_id: viewId,
},
orderBy: {
order: 'asc',
},
},
);
for (const viewColumn of viewColumns) {
viewColumn.meta = deserializeJSON(viewColumn.meta);
}
await NocoCache.setList(
CacheScope.CALENDAR_VIEW_COLUMN,
[viewId],
viewColumns,
);
}
viewColumns.sort(
(a, b) =>
(a.order != null ? a.order : Infinity) -
(b.order != null ? b.order : Infinity),
);
return viewColumns?.map((v) => new CalendarViewColumn(v));
}
static async update(
columnId: string,
body: Partial<CalendarViewColumn>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(body, [
'show',
'order',
'underline',
'bold',
'italic',
]);
// get existing cache
const key = `${CacheScope.CALENDAR_VIEW_COLUMN}:${columnId}`;
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
Object.assign(o, updateObj);
// set cache
await NocoCache.set(key, o);
}
// update meta
return await ncMeta.metaUpdate(
null,
null,
MetaTable.CALENDAR_VIEW_COLUMNS,
updateObj,
columnId,
);
}
}

9
packages/nocodb/src/models/FormViewColumn.ts

@ -49,11 +49,12 @@ export default class FormViewColumn implements FormColumnType {
viewColumn.meta && typeof viewColumn.meta === 'string'
? JSON.parse(viewColumn.meta)
: viewColumn.meta;
await NocoCache.set(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
viewColumn,
);
}
await NocoCache.set(
`${CacheScope.FORM_VIEW_COLUMN}:${formViewColumnId}`,
viewColumn,
);
return viewColumn && new FormViewColumn(viewColumn);
}

526
packages/nocodb/src/models/View.ts

@ -5,7 +5,10 @@ import FormView from '~/models/FormView';
import GridView from '~/models/GridView';
import KanbanView from '~/models/KanbanView';
import GalleryView from '~/models/GalleryView';
import CalendarView from '~/models/CalendarView';
import GridViewColumn from '~/models/GridViewColumn';
import CalendarViewColumn from '~/models/CalendarViewColumn';
import CalendarRange from '~/models/CalendarRange';
import Sort from '~/models/Sort';
import Filter from '~/models/Filter';
import GalleryViewColumn from '~/models/GalleryViewColumn';
@ -33,6 +36,7 @@ type ViewColumn =
| FormViewColumn
| GalleryViewColumn
| KanbanViewColumn
| CalendarViewColumn
| MapViewColumn;
type ViewColumnEnrichedWithTitleAndName = ViewColumn & {
@ -55,13 +59,20 @@ export default class View implements ViewType {
fk_model_id: string;
model?: Model;
view?: FormView | GridView | KanbanView | GalleryView | MapView;
view?:
| FormView
| GridView
| KanbanView
| GalleryView
| MapView
| CalendarView;
columns?: Array<
| FormViewColumn
| GridViewColumn
| GalleryViewColumn
| KanbanViewColumn
| MapViewColumn
| CalendarViewColumn
>;
sorts: Sort[];
@ -75,64 +86,6 @@ export default class View implements ViewType {
Object.assign(this, data);
}
async getModel(ncMeta = Noco.ncMeta): Promise<Model> {
return (this.model = await Model.getByIdOrName(
{ id: this.fk_model_id },
ncMeta,
));
}
async getModelWithInfo(ncMeta = Noco.ncMeta): Promise<Model> {
return (this.model = await Model.getWithInfo(
{ id: this.fk_model_id },
ncMeta,
));
}
async getView<T>(): Promise<T> {
switch (this.type) {
case ViewTypes.GRID:
this.view = await GridView.get(this.id);
break;
case ViewTypes.KANBAN:
this.view = await KanbanView.get(this.id);
break;
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id);
break;
}
return <T>this.view;
}
async getViewWithInfo(
ncMeta = Noco.ncMeta,
): Promise<FormView | GridView | KanbanView | GalleryView> {
switch (this.type) {
case ViewTypes.GRID:
this.view = await GridView.getWithInfo(this.id, ncMeta);
break;
case ViewTypes.KANBAN:
this.view = await KanbanView.get(this.id, ncMeta);
break;
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id, ncMeta);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id, ncMeta);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id, ncMeta);
break;
}
return this.view;
}
public static async get(viewId: string, ncMeta = Noco.ncMeta) {
let view =
viewId &&
@ -256,24 +209,14 @@ export default class View implements ViewType {
return viewsList?.map((v) => new View(v));
}
public async getFilters(ncMeta = Noco.ncMeta) {
return (this.filter = (await Filter.getFilterObject(
{
viewId: this.id,
},
ncMeta,
)) as any);
}
public async getSorts(ncMeta = Noco.ncMeta) {
return (this.sorts = await Sort.list({ viewId: this.id }, ncMeta));
}
static async insert(
view: Partial<View> &
Partial<FormView | GridView | GalleryView | KanbanView | MapView> & {
Partial<
FormView | GridView | GalleryView | KanbanView | MapView | CalendarView
> & {
copy_from_id?: string;
fk_grp_col_id?: string;
calendar_range?: Partial<CalendarRange>[];
},
ncMeta = Noco.ncMeta,
) {
@ -378,6 +321,25 @@ export default class View implements ViewType {
ncMeta,
);
break;
case ViewTypes.CALENDAR: {
const obj = extractProps(view, ['calendar_range']);
if (!obj.calendar_range) break;
const calendarRange = obj.calendar_range as Partial<CalendarRange>[];
calendarRange.forEach((range) => {
range.fk_view_id = view_id;
});
await CalendarView.insert(
{
...(copyFromView?.view || {}),
...view,
fk_view_id: view_id,
},
ncMeta,
);
await CalendarRange.bulkInsert(calendarRange, ncMeta);
}
}
if (copyFromView) {
@ -429,6 +391,11 @@ export default class View implements ViewType {
let order = 1;
let galleryShowLimit = 0;
let kanbanShowLimit = 0;
let calendarRanges: Array<string> | null = null;
if (view.type === ViewTypes.CALENDAR) {
calendarRanges = await View.getRangeColumnsAsArray(view_id, ncMeta);
}
if (view.type === ViewTypes.KANBAN && !copyFromView) {
// sort by display value & attachment first, then by singleLineText & Number
@ -455,6 +422,9 @@ export default class View implements ViewType {
for (const vCol of columns) {
let show = 'show' in vCol ? vCol.show : true;
const underline = false;
const bold = false;
const italic = false;
if (view.type === ViewTypes.GALLERY) {
const galleryView = await GalleryView.get(view_id, ncMeta);
@ -485,6 +455,13 @@ export default class View implements ViewType {
// other columns will be hidden
show = false;
}
} else if (view.type === ViewTypes.CALENDAR && !copyFromView) {
const calendarView = await CalendarView.get(view_id, ncMeta);
if (calendarRanges && calendarRanges.includes(vCol.id)) {
show = true;
} else
show = vCol.id === calendarView?.fk_cover_image_col_id || vCol.pv;
// Show all Fields in Ranges
} else if (view.type === ViewTypes.MAP && !copyFromView) {
const mapView = await MapView.get(view_id, ncMeta);
if (vCol.id === mapView?.fk_geo_data_col_id) {
@ -506,6 +483,9 @@ export default class View implements ViewType {
view_id,
fk_column_id: vCol.fk_column_id || vCol.id,
show,
underline,
bold,
italic,
id: null,
},
ncMeta,
@ -528,6 +508,18 @@ export default class View implements ViewType {
});
}
static async getRangeColumnsAsArray(viewId: string, ncMeta) {
const calRange = await CalendarRange.read(viewId, ncMeta);
if (calRange) {
const calIds: Set<string> = new Set();
calRange.ranges.forEach((range) => {
calIds.add(range.fk_from_column_id);
});
return Array.from(calIds) as Array<string>;
}
return [];
}
static async insertColumnToAllViews(
param: {
fk_column_id: any;
@ -582,6 +574,15 @@ export default class View implements ViewType {
case ViewTypes.KANBAN:
await KanbanViewColumn.insert(modifiedInsertObj, ncMeta);
break;
case ViewTypes.CALENDAR:
await CalendarViewColumn.insert(
{
...insertObj,
fk_view_id: view.id,
},
ncMeta,
);
break;
}
}
}
@ -591,9 +592,13 @@ export default class View implements ViewType {
view_id: any;
order;
show;
underline?;
bold?;
italic?;
fk_column_id;
id?: string;
} & Partial<FormViewColumn>,
} & Partial<FormViewColumn> &
Partial<CalendarViewColumn>,
ncMeta = Noco.ncMeta,
) {
const view = await this.get(param.view_id, ncMeta);
@ -655,6 +660,17 @@ export default class View implements ViewType {
);
}
break;
case ViewTypes.CALENDAR:
{
col = await CalendarViewColumn.insert(
{
...param,
fk_view_id: view.id,
},
ncMeta,
);
}
break;
}
return col;
@ -678,6 +694,7 @@ export default class View implements ViewType {
| GalleryViewColumn
| KanbanViewColumn
| MapViewColumn
| CalendarViewColumn
>
> {
let columns: Array<GridViewColumn | any> = [];
@ -700,15 +717,14 @@ export default class View implements ViewType {
case ViewTypes.KANBAN:
columns = await KanbanViewColumn.list(viewId, ncMeta);
break;
case ViewTypes.CALENDAR:
columns = await CalendarViewColumn.list(viewId, ncMeta);
break;
}
return columns;
}
async getColumns(ncMeta = Noco.ncMeta) {
return (this.columns = await View.getColumns(this.id, ncMeta));
}
static async getViewColumnId(
{
viewId,
@ -749,6 +765,11 @@ export default class View implements ViewType {
tableName = MetaTable.KANBAN_VIEW_COLUMNS;
cacheScope = CacheScope.KANBAN_VIEW_COLUMN;
break;
case ViewTypes.CALENDAR:
tableName = MetaTable.CALENDAR_VIEW_COLUMNS;
cacheScope = CacheScope.CALENDAR_VIEW_COLUMN;
break;
}
@ -800,11 +821,14 @@ export default class View implements ViewType {
table = MetaTable.FORM_VIEW_COLUMNS;
cacheScope = CacheScope.FORM_VIEW_COLUMN;
break;
case ViewTypes.CALENDAR:
table = MetaTable.CALENDAR_VIEW_COLUMNS;
cacheScope = CacheScope.CALENDAR_VIEW_COLUMN;
}
const updateObj = extractProps(colData, ['order', 'show']);
// keep primary_value_column always visible and first in grid view
if (view.type === ViewTypes.GRID) {
if (view.type === ViewTypes.GRID || view.type === ViewTypes.CALENDAR) {
const primary_value_column_meta = await ncMeta.metaGet2(
null,
null,
@ -912,7 +936,6 @@ export default class View implements ViewType {
order: colData.order,
show: colData.show,
});
break;
case ViewTypes.MAP:
return await MapViewColumn.insert({
fk_view_id: viewId,
@ -920,7 +943,6 @@ export default class View implements ViewType {
order: colData.order,
show: colData.show,
});
break;
case ViewTypes.FORM:
return await FormViewColumn.insert({
fk_view_id: viewId,
@ -928,6 +950,13 @@ export default class View implements ViewType {
order: colData.order,
show: colData.show,
});
case ViewTypes.CALENDAR:
return await CalendarViewColumn.insert({
fk_view_id: viewId,
fk_column_id: fkColId,
order: colData.order,
show: colData.show,
});
}
return await ncMeta.metaInsert2(view.base_id, view.source_id, table, {
fk_view_id: viewId,
@ -1128,6 +1157,7 @@ export default class View implements ViewType {
await Sort.deleteAll(viewId, ncMeta);
await Filter.deleteAll(viewId, ncMeta);
const table = this.extractViewTableName(view);
const tableScope = this.extractViewTableNameScope(view);
const columnTable = this.extractViewColumnsTableName(view);
const columnTableScope = this.extractViewColumnsTableNameScope(view);
@ -1142,6 +1172,17 @@ export default class View implements ViewType {
`${tableScope}:${viewId}`,
CacheDelDirection.CHILD_TO_PARENT,
);
// For Calendar View, delete the range associated with viewId
if (view.type === ViewTypes.CALENDAR) {
await ncMeta.metaDelete(null, null, MetaTable.CALENDAR_VIEW_RANGE, {
fk_view_id: viewId,
});
await NocoCache.deepDel(
`${CacheScope.CALENDAR_VIEW_RANGE}:${viewId}`,
CacheDelDirection.CHILD_TO_PARENT,
);
}
await NocoCache.deepDel(
`${columnTableScope}:${viewId}`,
CacheDelDirection.CHILD_TO_PARENT,
@ -1164,94 +1205,6 @@ export default class View implements ViewType {
);
}
private static extractViewColumnsTableName(view: View) {
let table;
switch (view.type) {
case ViewTypes.GRID:
table = MetaTable.GRID_VIEW_COLUMNS;
break;
case ViewTypes.GALLERY:
table = MetaTable.GALLERY_VIEW_COLUMNS;
break;
case ViewTypes.KANBAN:
table = MetaTable.KANBAN_VIEW_COLUMNS;
break;
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW_COLUMNS;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW_COLUMNS;
break;
}
return table;
}
private static extractViewTableName(view: View) {
let table;
switch (view.type) {
case ViewTypes.GRID:
table = MetaTable.GRID_VIEW;
break;
case ViewTypes.GALLERY:
table = MetaTable.GALLERY_VIEW;
break;
case ViewTypes.KANBAN:
table = MetaTable.KANBAN_VIEW;
break;
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW;
break;
}
return table;
}
private static extractViewColumnsTableNameScope(view: View) {
let scope;
switch (view.type) {
case ViewTypes.GRID:
scope = CacheScope.GRID_VIEW_COLUMN;
break;
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW_COLUMN;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW_COLUMN;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW_COLUMN;
break;
case ViewTypes.FORM:
scope = CacheScope.FORM_VIEW_COLUMN;
break;
}
return scope;
}
private static extractViewTableNameScope(view: View) {
let scope;
switch (view.type) {
case ViewTypes.GRID:
scope = CacheScope.GRID_VIEW;
break;
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW;
break;
case ViewTypes.FORM:
scope = CacheScope.FORM_VIEW;
break;
}
return scope;
}
static async showAllColumns(
viewId,
ignoreColdIds = [],
@ -1403,10 +1356,6 @@ export default class View implements ViewType {
);
}
async delete(ncMeta = Noco.ncMeta) {
await View.delete(this.id, ncMeta);
}
static async shareViewList(tableId, ncMeta = Noco.ncMeta) {
const cachedList = await NocoCache.getList(CacheScope.VIEW, [tableId]);
let { list: sharedViews } = cachedList;
@ -1582,6 +1531,7 @@ export default class View implements ViewType {
| FormViewColumn
| KanbanViewColumn
| MapViewColumn
| CalendarViewColumn
)[];
},
view: View,
@ -1688,6 +1638,19 @@ export default class View implements ViewType {
}
} else if (view.type === ViewTypes.FORM && isSystemColumn(column)) {
show = false;
} else if (view.type === ViewTypes.CALENDAR && !copyFromView) {
const calendarRange = await CalendarRange.read(view.id, ncMeta);
if (!calendarRange) break;
const calendarRangeColumns = calendarRange.ranges
.map((range) => [
range.fk_from_column_id,
(range as any).fk_to_column_id,
])
.flat();
if (calendarRangeColumns.includes(column.id)) {
show = true;
}
}
insertObjs.push({
@ -1742,14 +1705,24 @@ export default class View implements ViewType {
insertObjs,
);
break;
case ViewTypes.CALENDAR:
await ncMeta.bulkMetaInsert(
null,
null,
MetaTable.CALENDAR_VIEW_COLUMNS,
insertObjs,
);
}
}
static async insertMetaOnly(
view: Partial<View> &
Partial<FormView | GridView | GalleryView | KanbanView | MapView> & {
Partial<
FormView | GridView | GalleryView | KanbanView | MapView | CalendarView
> & {
copy_from_id?: string;
fk_grp_col_id?: string;
calendar_range?: Partial<CalendarRange>[];
},
model: {
getColumns: () => Promise<Column[]>;
@ -1856,6 +1829,26 @@ export default class View implements ViewType {
ncMeta,
);
break;
case ViewTypes.CALENDAR: {
const obj = extractProps(view, ['calendar_range']);
if (!obj.calendar_range) break;
const calendarRange = obj.calendar_range as Partial<CalendarRange>[];
calendarRange.forEach((range) => {
range.fk_view_id = view_id;
});
await CalendarRange.bulkInsert(calendarRange, ncMeta);
await CalendarView.insert(
{
...(copyFromView?.view || {}),
...view,
fk_view_id: view_id,
},
ncMeta,
);
break;
}
}
// copy from view
@ -1942,4 +1935,189 @@ export default class View implements ViewType {
return insertedView;
}
private static extractViewColumnsTableName(view: View) {
let table;
switch (view.type) {
case ViewTypes.GRID:
table = MetaTable.GRID_VIEW_COLUMNS;
break;
case ViewTypes.GALLERY:
table = MetaTable.GALLERY_VIEW_COLUMNS;
break;
case ViewTypes.KANBAN:
table = MetaTable.KANBAN_VIEW_COLUMNS;
break;
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW_COLUMNS;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW_COLUMNS;
break;
case ViewTypes.CALENDAR:
table = MetaTable.CALENDAR_VIEW_COLUMNS;
break;
}
return table;
}
private static extractViewTableName(view: View) {
let table;
switch (view.type) {
case ViewTypes.GRID:
table = MetaTable.GRID_VIEW;
break;
case ViewTypes.GALLERY:
table = MetaTable.GALLERY_VIEW;
break;
case ViewTypes.KANBAN:
table = MetaTable.KANBAN_VIEW;
break;
case ViewTypes.FORM:
table = MetaTable.FORM_VIEW;
break;
case ViewTypes.MAP:
table = MetaTable.MAP_VIEW;
break;
case ViewTypes.CALENDAR:
table = MetaTable.CALENDAR_VIEW;
break;
}
return table;
}
private static extractViewColumnsTableNameScope(view: View) {
let scope;
switch (view.type) {
case ViewTypes.GRID:
scope = CacheScope.GRID_VIEW_COLUMN;
break;
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW_COLUMN;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW_COLUMN;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW_COLUMN;
break;
case ViewTypes.FORM:
scope = CacheScope.FORM_VIEW_COLUMN;
break;
case ViewTypes.CALENDAR:
scope = CacheScope.CALENDAR_VIEW_COLUMN;
break;
}
return scope;
}
private static extractViewTableNameScope(view: View) {
let scope;
switch (view.type) {
case ViewTypes.GRID:
scope = CacheScope.GRID_VIEW;
break;
case ViewTypes.GALLERY:
scope = CacheScope.GALLERY_VIEW;
break;
case ViewTypes.MAP:
scope = CacheScope.MAP_VIEW;
break;
case ViewTypes.KANBAN:
scope = CacheScope.KANBAN_VIEW;
break;
case ViewTypes.FORM:
scope = CacheScope.FORM_VIEW;
break;
case ViewTypes.CALENDAR:
scope = CacheScope.CALENDAR_VIEW;
break;
}
return scope;
}
async getModel(ncMeta = Noco.ncMeta): Promise<Model> {
return (this.model = await Model.getByIdOrName(
{ id: this.fk_model_id },
ncMeta,
));
}
async getModelWithInfo(ncMeta = Noco.ncMeta): Promise<Model> {
return (this.model = await Model.getWithInfo(
{ id: this.fk_model_id },
ncMeta,
));
}
async getView<T>(): Promise<T> {
switch (this.type) {
case ViewTypes.GRID:
this.view = await GridView.get(this.id);
break;
case ViewTypes.KANBAN:
this.view = await KanbanView.get(this.id);
break;
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id);
break;
case ViewTypes.CALENDAR:
this.view = await CalendarView.get(this.id);
break;
}
return <T>this.view;
}
async getViewWithInfo(
ncMeta = Noco.ncMeta,
): Promise<FormView | GridView | KanbanView | GalleryView> {
switch (this.type) {
case ViewTypes.GRID:
this.view = await GridView.getWithInfo(this.id, ncMeta);
break;
case ViewTypes.KANBAN:
this.view = await KanbanView.get(this.id, ncMeta);
break;
case ViewTypes.GALLERY:
this.view = await GalleryView.get(this.id, ncMeta);
break;
case ViewTypes.MAP:
this.view = await MapView.get(this.id, ncMeta);
break;
case ViewTypes.FORM:
this.view = await FormView.get(this.id, ncMeta);
break;
case ViewTypes.CALENDAR:
this.view = await CalendarView.get(this.id, ncMeta);
break;
}
return this.view;
}
public async getFilters(ncMeta = Noco.ncMeta) {
return (this.filter = (await Filter.getFilterObject(
{
viewId: this.id,
},
ncMeta,
)) as any);
}
public async getSorts(ncMeta = Noco.ncMeta) {
return (this.sorts = await Sort.list({ viewId: this.id }, ncMeta));
}
async getColumns(ncMeta = Noco.ncMeta) {
return (this.columns = await View.getColumns(this.id, ncMeta));
}
async delete(ncMeta = Noco.ncMeta) {
await View.delete(this.id, ncMeta);
}
}

3
packages/nocodb/src/models/index.ts

@ -3,6 +3,9 @@ export { default as Audit } from './Audit';
export { default as BarcodeColumn } from './BarcodeColumn';
export { default as Source } from './Source';
export { default as Column } from './Column';
export { default as CalendarView } from './CalendarView';
export { default as CalendarViewColumn } from './CalendarViewColumn';
export { default as CalendarRange } from './CalendarRange';
export { default as Filter } from './Filter';
export { default as FormulaColumn } from './FormulaColumn';
export { default as FormView } from './FormView';

33
packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts

@ -1,13 +1,18 @@
import { UITypes, ViewTypes } from 'nocodb-sdk';
import { isLinksOrLTAR, isVirtualCol, UITypes, ViewTypes } from 'nocodb-sdk';
import { Injectable } from '@nestjs/common';
import papaparse from 'papaparse';
import debug from 'debug';
import { isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk';
import { elapsedTime, initTime } from '../../helpers';
import type { Readable } from 'stream';
import type { UserType, ViewCreateReqType } from 'nocodb-sdk';
import type { LinkToAnotherRecordColumn, User, View } from '~/models';
import type { Readable } from 'stream';
import type {
CalendarView,
LinkToAnotherRecordColumn,
User,
View,
} from '~/models';
import type { NcRequest } from '~/interface/config';
import { Base, Column, Model, Source } from '~/models';
import {
findWithIdentifier,
generateUniqueName,
@ -18,7 +23,6 @@ import {
withoutNull,
} from '~/helpers/exportImportHelpers';
import { NcError } from '~/helpers/catchError';
import { Base, Column, Model, Source } from '~/models';
import { TablesService } from '~/services/tables.service';
import { ColumnsService } from '~/services/columns.service';
import { FiltersService } from '~/services/filters.service';
@ -28,6 +32,7 @@ import { GridColumnsService } from '~/services/grid-columns.service';
import { FormColumnsService } from '~/services/form-columns.service';
import { GridsService } from '~/services/grids.service';
import { FormsService } from '~/services/forms.service';
import { CalendarsService } from '~/services/calendars.service';
import { GalleriesService } from '~/services/galleries.service';
import { KanbansService } from '~/services/kanbans.service';
import { HooksService } from '~/services/hooks.service';
@ -52,6 +57,7 @@ export class ImportService {
private gridsService: GridsService,
private formsService: FormsService,
private galleriesService: GalleriesService,
private calendarsService: CalendarsService,
private kanbansService: KanbansService,
private bulkDataService: BulkDataAliasService,
private hooksService: HooksService,
@ -1087,6 +1093,7 @@ export class ImportService {
break;
case ViewTypes.GALLERY:
case ViewTypes.KANBAN:
case ViewTypes.CALENDAR:
break;
}
@ -1214,6 +1221,22 @@ export class ImportService {
}
return fview;
}
case ViewTypes.CALENDAR: {
return await this.calendarsService.calendarViewCreate({
tableId: md.id,
calendar: {
...vw,
calendar_range: (vw.view as CalendarView).calendar_range.map(
(a) => ({
fk_from_column_id: idMap.get(a.fk_from_column_id),
fk_to_column_id: idMap.get((a as any).fk_to_column_id),
}),
),
} as ViewCreateReqType,
user,
req,
});
}
case ViewTypes.GALLERY: {
const glview = await this.galleriesService.galleryViewCreate({
tableId: md.id,

5
packages/nocodb/src/modules/metas/metas.module.ts

@ -10,6 +10,7 @@ import { AttachmentsSecureController } from '~/controllers/attachments-secure.co
import { AuditsController } from '~/controllers/audits.controller';
import { SourcesController } from '~/controllers/sources.controller';
import { CachesController } from '~/controllers/caches.controller';
import { CalendarsController } from '~/controllers/calendars.controller';
import { ColumnsController } from '~/controllers/columns.controller';
import { FiltersController } from '~/controllers/filters.controller';
import { FormColumnsController } from '~/controllers/form-columns.controller';
@ -41,6 +42,7 @@ import { AuditsService } from '~/services/audits.service';
import { SourcesService } from '~/services/sources.service';
import { BulkDataAliasService } from '~/services/bulk-data-alias.service';
import { CachesService } from '~/services/caches.service';
import { CalendarsService } from '~/services/calendars.service';
import { ColumnsService } from '~/services/columns.service';
import { FiltersService } from '~/services/filters.service';
import { FormColumnsService } from '~/services/form-columns.service';
@ -97,6 +99,7 @@ export const metaModuleMetadata = {
AuditsController,
SourcesController,
CachesController,
CalendarsController,
ColumnsController,
FiltersController,
FormColumnsController,
@ -136,6 +139,7 @@ export const metaModuleMetadata = {
AuditsService,
SourcesService,
CachesService,
CalendarsService,
ColumnsService,
FiltersService,
FormColumnsService,
@ -176,6 +180,7 @@ export const metaModuleMetadata = {
ViewsService,
ViewColumnsService,
GridsService,
CalendarsService,
GridColumnsService,
FormsService,
FormColumnsService,

77
packages/nocodb/src/schema/swagger-v2.json

@ -7273,6 +7273,83 @@
"description": "List Shared View Grouped Data"
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/countByDate": {
"parameters": [
{
"schema": {
"type": "string",
"example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f"
},
"name": "sharedViewUuid",
"in": "path",
"required": true,
"description": "Shared View UUID"
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "Count of Records in Dates in Calendar View",
"operationId": "public-calendar-count",
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "sort"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"Public"
]
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/rows": {
"parameters": [
{

687
packages/nocodb/src/schema/swagger.json

@ -8351,6 +8351,196 @@
]
}
},
"/api/v1/db/meta/tables/{tableId}/calendars": {
"parameters": [
{
"schema": {
"$ref": "#/components/schemas/Id",
"example": "md_w9gpnaousnfss1",
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Unique Table ID"
}
],
"post": {
"summary": "Create Calendar View",
"operationId": "db-view-calendar-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/View"
},
"examples": {
"Example 1": {
"value": {
"id": "vw_569sqsrp2vuff4",
"source_id": "ds_a95vextjl510z7",
"base_id": "p_slkm6i3v31q4bc",
"fk_model_id": "md_8hr3xndx8umuce",
"title": "Calendar-1",
"type": 6,
"is_default": null,
"show_system_fields": null,
"lock_type": "collaborative",
"uuid": null,
"password": null,
"show": true,
"order": 5,
"created_at": "2023-03-13T07:29:21.387Z",
"updated_at": "2023-03-13T07:29:21.387Z",
"meta": {}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB View"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ViewCreateReq"
},
"examples": {
"Example 1": {
"value": {
"title": "My Calendar View",
"type": 4,
"copy_from_id": null,
"fk_grp_col_id": null,
"fk_geo_data_col_id": null,
"calendar_range": [
{
"fk_from_column_id": "cl_g0a89q9xdry3lu",
"fk_to_column_id": "cl_g0a89q9xdry3lu"
}
]
}
}
}
}
}
},
"description": "Create a new Calendar View",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v1/db/meta/calendars/{calendarViewId}": {
"parameters": [
{
"schema": {
"type": "string",
"example": "vw_1eq2wk2xe3a9j5"
},
"name": "calendarViewId",
"in": "path",
"required": true,
"description": "Unique Calendar View ID"
}
],
"patch": {
"summary": "Update Calendar View",
"operationId": "db-view-calendar-update",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "number"
},
"examples": {
"Example 1": {
"value": 1
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB View"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarUpdateReq"
},
"examples": {
"Example 1": {
"value": {
"fk_cover_image_col_id": "cl_ib8l4j1kiu1efx",
"title": "Calendar - 2",
"calendar_range": [
{
"id": "cl_gpoc4d1dfef",
"fk_from_column_id": "cl_g0a89q9xdry3lu",
"fk_to_column_id": "cl_g0a89q9xdry3lu"
}
]
}
}
}
}
}
},
"description": "Update the Calendar View data with Calendar ID",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"get": {
"summary": "Get Calendar View",
"operationId": "db-view-calendar-read",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Calendar"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB View"
],
"description": "Get the Calendar View data by Calendar ID",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v1/db/meta/projects/{baseId}/meta-diff": {
"parameters": [
{
@ -9864,6 +10054,101 @@
}
}
},
"/api/v1/db/data/{orgs}/{baseName}/{tableName}/views/{viewName}/countByDate/" : {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "orgs",
"in": "path",
"required": true,
"description": "Organisation Name. Currently `noco` will be used."
},
{
"schema": {
"type": "string"
},
"name": "baseName",
"in": "path",
"required": true,
"description": "Base Name"
},
{
"schema": {
"type": "string"
},
"name": "tableName",
"in": "path",
"required": true,
"description": "Table Name"
},
{
"schema": {
"type": "string"
},
"name": "viewName",
"in": "path",
"required": true
}
],
"get": {
"summary": "Count of Records in Dates in Calendar View",
"operationId": "db-view-row-calendar-count",
"description": "Get the count of table view rows grouped by the dates",
"tags": [
"DB View Row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "sort"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v1/db/data/{orgs}/{baseName}/{tableName}/views/{viewName}/groupby": {
"parameters": [
{
@ -11829,8 +12114,85 @@
"name": "filterArrJson",
"description": "Used for multiple filter queries"
}
],
"description": "List Shared View Grouped Data"
],
"description": "List Shared View Grouped Data"
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/countByDate": {
"parameters": [
{
"schema": {
"type": "string",
"example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f"
},
"name": "sharedViewUuid",
"in": "path",
"required": true,
"description": "Shared View UUID"
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "Count of Records in Dates in Calendar View",
"operationId": "public-calendar-count",
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "sort"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"Public"
]
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/rows": {
@ -14619,6 +14981,10 @@
"type": "integer",
"description": "Kanban Count"
},
"calendarCount": {
"type": "integer",
"description": "Calendar Count"
},
"total": {
"type": "integer",
"description": "Total View Count"
@ -14639,6 +15005,10 @@
"type": "integer",
"description": "Shared Kanban Count"
},
"sharedCalendarCount": {
"type": "integer",
"description": "Shared Calendar Count"
},
"sharedTotal": {
"type": "integer",
"description": "Shared Total View Count"
@ -14704,11 +15074,13 @@
"gridCount": 3,
"galleryCount": 0,
"kanbanCount": 0,
"calendarCount": 0,
"total": 3,
"sharedFormCount": 0,
"sharedGridCount": 0,
"sharedGalleryCount": 0,
"sharedKanbanCount": 0,
"sharedCalendarCount": 0,
"sharedTotal": 0,
"sharedLockedCount": 0
},
@ -14744,11 +15116,13 @@
"gridCount": 3,
"galleryCount": 0,
"kanbanCount": 0,
"calendarCount": 0,
"total": 3,
"sharedFormCount": 0,
"sharedGridCount": 0,
"sharedGalleryCount": 0,
"sharedKanbanCount": 0,
"sharedCalendarCount": 0,
"sharedTotal": 0,
"sharedLockedCount": 0
},
@ -20071,6 +20445,270 @@
"id": "9zirjgj9k1gqa"
}
},
"Calendar": {
"description": "Model for Calendar",
"examples": [
{
"id": "vw_wqs4zheuo5lgdy",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"fk_cover_image_col_id": null,
"columns": [
{
"id": "kvc_2skkg5mi1eb37f",
"fk_column_id": "cl_hzos4ghyncqi4k",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"source_id": "ds_hd4ojj0xpquaam",
"base_id": "p_kzfl5lb0t3tcok",
"title": "string",
"show": 1,
"bold": 0,
"italic": 0,
"underline": 0,
"order": "1"
}
],
"calendar_range": [
{
"id": "kvc_2skkg5mi1eb37f",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"fk_from_column_id": "cl_hzos4ghyncqi4k",
"fk_to_column_id": "cl_hzos4ghyncqi4k"
}
],
"meta": null,
"title": "My Calendar"
}
],
"title": "Calendar Model",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"fk_view_id": {
"$ref": "#/components/schemas/Id",
"x-stoplight": {
"id": "1kgw1w06b97nl"
},
"description": "View ID"
},
"fk_cover_image_col_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Cover Image Column ID"
},
"columns": {
"type": "array",
"description": "Calendar Columns",
"items": {
"$ref": "#/components/schemas/CalendarColumn"
}
},
"calendar_range": {
"type": "array",
"description": "Calendar Date Range",
"items": {
"$ref": "#/components/schemas/CalendarRange"
}
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "Meta Info for Kanban"
},
"title": {
"type": "string",
"description": "Kanban Title",
"example": "My Kanban"
}
},
"x-stoplight": {
"id": "gu721t0zw7jqq"
}
},
"CalendarColumn": {
"description": "Model for Calendar Column",
"examples": [
{
"id": "kvc_2skkg5mi1eb37f",
"fk_column_id": "cl_hzos4ghyncqi4k",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"source_id": "ds_hd4ojj0xpquaam",
"base_id": "p_kzfl5lb0t3tcok",
"title": "string",
"show": 0,
"bold": 0,
"italic": 0,
"underline": 0,
"order": "1"
}
],
"title": "Calendar Column Model",
"type": "object",
"properties": {
"id": {
"$ref": "#/components/schemas/Id",
"description": "Unique ID"
},
"fk_column_id": {
"$ref": "#/components/schemas/Id",
"description": "Foreign Key to Column"
},
"fk_view_id": {
"$ref": "#/components/schemas/Id",
"x-stoplight": {
"id": "t1fy4zy561ih8"
},
"description": "Foreign Key to View"
},
"source_id": {
"$ref": "#/components/schemas/Id",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Baes ID\n"
},
"base_id": {
"$ref": "#/components/schemas/Id",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Base ID"
},
"title": {
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Base ID",
"type": "string"
},
"show": {
"$ref": "#/components/schemas/Bool",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Is this column shown?"
},
"bold": {
"$ref": "#/components/schemas/Bool",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Is this column shown as bold?"
},
"italic": {
"$ref": "#/components/schemas/Bool",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Is this column shown as italic?"
},
"underline": {
"$ref": "#/components/schemas/Bool",
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Is this column shown underlines?"
},
"order": {
"type": "number",
"x-stoplight": {
"id": "pbnchzgci5dwa"
},
"example": 1,
"description": "Column Order"
}
},
"x-stoplight": {
"id": "psbv6c6y9qvbu"
}
},
"CalendarRange": {
"description": "Model for Calendar Date Range",
"examples": [
{
"id": "kvc_2skkg5mi1eb37f",
"fk_from_column_id": "cl_hzos4ghyncqi4k",
"fk_to_column_id": "cl_hzos4ghyncqi4k",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"label": "string"
}
],
"title": "Calendar Date Range Model",
"type": "object",
"properties": {
"fk_from_column_id": {
"$ref": "#/components/schemas/Id",
"description": "Foreign Key to Column"
},
"fk_view_id": {
"$ref": "#/components/schemas/StringOrNull",
"x-stoplight": {
"id": "t1fy4zy561ih8"
},
"description": "Foreign Key to View"
},
"label": {
"x-stoplight": {
"id": "uqq8xmyz97t1u"
},
"description": "Base ID",
"type": "string"
}
},
"x-stoplight": {
"id": "psbv6c6y9qvbu"
}
},
"CalendarUpdateReq": {
"description": "Model for Calendar Update Request",
"examples": [
{
"fk_cover_image_col_id": "cl_ib8l4j1kiu1efx",
"title": "Calendar 2",
"calendar_range": [
{
"id": "kvc_2skkg5mi1eb37f",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"fk_from_column_id": "cl_hzos4ghyncqi4k",
"fk_to_column_id": "cl_hzos4ghyncqi4k"
}
]
}
],
"title": "Calendar Update Request Model",
"type": "object",
"properties": {
"fk_cover_image_col_id": {
"$ref": "#/components/schemas/StringOrNull",
"x-stoplight": {
"id": "81wn4hzj76wod"
},
"description": "Foreign Key to Cover Image Column"
},
"title": {
"type": "string",
"description": "Calendar Title",
"example": "Calendar 01"
},
"calendar_range": {
"type": "array",
"description": "Calendar Columns",
"items": {
"$ref": "#/components/schemas/CalendarRange"
}
},
"meta": {
"$ref": "#/components/schemas/Meta",
"x-stoplight": {
"id": "stsvdmkli1b0r"
},
"description": "Meta Info"
}
},
"x-stoplight": {
"id": "9zirjgj9k1gqa"
}
},
"LicenseReq": {
"description": "Model for Kanban Request",
"examples": [
@ -22123,6 +22761,31 @@
],
"title": "TextOrNull Model"
},
"CalendarRangeOrNull": {
"description": "Model for CalendarRangeOrNull",
"example": [{
"id": "kvc_2skkg5mi1eb37f",
"fk_from_column_id": "cl_hzos4ghyncqi4k",
"fk_to_column_id": "cl_hzos4ghyncqi4k",
"fk_view_id": "vw_wqs4zheuo5lgdy",
"label": "string"
}],
"oneOf": [
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/CalendarRange"
}
}
],
"title": "CalendarRangeOrNull Model",
"x-stoplight": {
"id": "p1g7xrgdsn540"
}
},
"StringOrNull": {
"description": "Model for StringOrNull",
"examples": [
@ -23195,6 +23858,9 @@
},
{
"$ref": "#/components/schemas/Map"
},
{
"$ref": "#/components/schemas/Calendar"
}
],
"description": "Associated View Model"
@ -23377,6 +24043,19 @@
"fk_grp_col_id": "cl_g0a89q9xdry3lu",
"fk_geo_data_col_id": null
},
{
"title": "My Calendar View",
"type": 6,
"copy_from_id": null,
"fk_grp_col_id": null,
"fk_geo_data_col_id": null,
"calendar_range": [
{
"fk_from_column_id": "cl_5jestblzneb649",
"fk_to_column_id": "cl_5jestblzneb649"
}
]
},
{
"title": "My Map View",
"type": 5,
@ -23406,6 +24085,10 @@
"fk_geo_data_col_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Geo Data Column. Used in creating Map View."
},
"calendar_range": {
"description": "Calendar Range or Null",
"$ref": "#/components/schemas/CalendarRangeOrNull"
}
},
"required": [

93
packages/nocodb/src/services/calendars.service.ts

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { AppEvents, ViewTypes } from 'nocodb-sdk';
import type {
CalendarUpdateReqType,
UserType,
ViewCreateReqType,
} from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers';
import { NcError } from '~/helpers/catchError';
import { CalendarView, Model, View } from '~/models';
import NocoCache from '~/cache/NocoCache';
import { CacheScope } from '~/utils/globals';
@Injectable()
export class CalendarsService {
constructor(private readonly appHooksService: AppHooksService) {}
async calendarViewGet(param: { calendarViewId: string }) {
return await CalendarView.get(param.calendarViewId);
}
async calendarViewCreate(param: {
tableId: string;
calendar: ViewCreateReqType;
user: UserType;
req: NcRequest;
}) {
validatePayload(
'swagger.json#/components/schemas/ViewCreateReq',
param.calendar,
);
const model = await Model.get(param.tableId);
const { id } = await View.insertMetaOnly(
{
...param.calendar,
fk_model_id: param.tableId,
type: ViewTypes.CALENDAR,
base_id: model.base_id,
source_id: model.source_id,
},
model,
);
const view = await View.get(id);
await NocoCache.appendToList(
CacheScope.VIEW,
[view.fk_model_id],
`${CacheScope.VIEW}:${id}`,
);
this.appHooksService.emit(AppEvents.VIEW_CREATE, {
view,
showAs: 'calendar',
user: param.user,
req: param.req,
});
return view;
}
async calendarViewUpdate(param: {
calendarViewId: string;
calendar: CalendarUpdateReqType;
req: NcRequest;
}) {
validatePayload(
'swagger.json#/components/schemas/CalendarUpdateReq',
param.calendar,
);
const view = await View.get(param.calendarViewId);
if (!view) {
NcError.badRequest('View not found');
}
const res = await CalendarView.update(param.calendarViewId, param.calendar);
this.appHooksService.emit(AppEvents.VIEW_UPDATE, {
view,
showAs: 'calendar',
req: param.req,
});
return res;
}
}

10
packages/nocodb/src/services/columns.service.ts

@ -25,6 +25,7 @@ import type CustomKnex from '~/db/CustomKnex';
import type SqlClient from '~/db/sql-client/lib/SqlClient';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { NcRequest } from '~/interface/config';
import { CalendarRange } from '~/models';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import ProjectMgrv2 from '~/db/sql-mgr/v2/ProjectMgrv2';
@ -2264,6 +2265,15 @@ export class ColumnsService {
}
/* falls through to default */
}
case UITypes.DateTime:
case UITypes.Date: {
if (await CalendarRange.IsColumnBeingUsedAsRange(column.id, ncMeta)) {
NcError.badRequest(
`The column '${column.column_name}' is being used in Calendar View. Please delete Calendar View first.`,
);
}
break;
}
// on deleting created/last modified columns, keep the column in table and delete the column from meta
case UITypes.CreatedTime:

4
packages/nocodb/src/services/data-table.service.ts

@ -3,11 +3,11 @@ import { isLinksOrLTAR, RelationTypes } from 'nocodb-sdk';
import { nocoExecute } from 'nc-help';
import { validatePayload } from 'src/helpers';
import type { LinkToAnotherRecordColumn } from '~/models';
import { Column, Model, Source, View } from '~/models';
import { DatasService } from '~/services/datas.service';
import { NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { Column, Model, Source, View } from '~/models';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
@Injectable()
@ -19,6 +19,7 @@ export class DataTableService {
modelId: string;
query: any;
viewId?: string;
ignorePagination?: boolean;
}) {
const { model, view } = await this.getModelAndView(param);
@ -27,6 +28,7 @@ export class DataTableService {
view,
query: param.query,
throwErrorIfInvalidParams: true,
ignorePagination: param.ignorePagination,
});
}

124
packages/nocodb/src/services/datas.service.ts

@ -1,12 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { isSystemColumn } from 'nocodb-sdk';
import { isSystemColumn, ViewTypes } from 'nocodb-sdk';
import * as XLSX from 'xlsx';
import papaparse from 'papaparse';
import { nocoExecute } from 'nc-help';
import dayjs from 'dayjs';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { PathParams } from '~/modules/datas/helpers';
import { getDbRows, getViewAndModelByAliasOrId } from '~/modules/datas/helpers';
import { Base, Column, Model, Source, View } from '~/models';
import { Base, CalendarRange, Column, Model, Source, View } from '~/models';
import { NcBaseError, NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
@ -19,7 +20,11 @@ export class DatasService {
constructor() {}
async dataList(
param: PathParams & { query: any; disableOptimization?: boolean },
param: PathParams & {
query: any;
disableOptimization?: boolean;
ignorePagination?: boolean;
},
) {
const { model, view } = await getViewAndModelByAliasOrId(param);
@ -28,6 +33,7 @@ export class DatasService {
view,
query: param.query,
throwErrorIfInvalidParams: true,
ignorePagination: param.ignorePagination,
});
}
@ -136,6 +142,7 @@ export class DatasService {
baseModel?: BaseModelSqlv2;
throwErrorIfInvalidParams?: boolean;
ignoreViewFilterAndSort?: boolean;
ignorePagination?: boolean;
}) {
const { model, view, query = {}, ignoreViewFilterAndSort = false } = param;
@ -164,6 +171,16 @@ export class DatasService {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
let options = {};
if (view && view.type === ViewTypes.CALENDAR && param.ignorePagination) {
{
options = {
ignorePagination: true,
};
}
}
const [count, data] = await Promise.all([
baseModel.count(listArgs, false, param.throwErrorIfInvalidParams),
(async () => {
@ -174,6 +191,7 @@ export class DatasService {
await baseModel.list(listArgs, {
ignoreViewFilterAndSort,
throwErrorIfInvalidParams: param.throwErrorIfInvalidParams,
...options,
}),
{},
listArgs,
@ -194,6 +212,67 @@ export class DatasService {
});
}
async getCalendarRecordCount(param: { viewId: string; query: any }) {
const { viewId, query = {} } = param;
const view = await View.get(viewId);
if (!view) NcError.notFound('View not found');
if (view.type !== ViewTypes.CALENDAR)
NcError.badRequest('View is not a calendar view');
const { ranges } = await CalendarRange.read(view.id);
if (!ranges.length) NcError.badRequest('No ranges found');
const model = await Model.getByIdOrName({
id: view.fk_model_id,
});
const data = await this.getDataList({
model,
view,
query,
});
if (!data) NcError.notFound('Data not found');
const dates: Array<string> = [];
ranges.forEach((range: any) => {
data.list.forEach((date) => {
const from =
date[
model.columns.find((c) => c.id === range.fk_from_column_id).title
];
let to;
if (range.fk_to_column_id) {
to =
date[
model.columns.find((c) => c.id === range.fk_to_column_id).title
];
}
if (from && to) {
const fromDt = dayjs(from);
const toDt = dayjs(to);
let current = fromDt;
while (current.isSameOrBefore(toDt)) {
dates.push(current.format('YYYY-MM-DD HH:mm:ssZ'));
current = current.add(1, 'day');
}
} else if (from) {
dates.push(dayjs(from).format('YYYY-MM-DD HH:mm:ssZ'));
}
});
});
return dates;
}
async getFindOne(param: { model: Model; view: View; query: any }) {
const { model, view, query = {} } = param;
@ -966,4 +1045,43 @@ export class DatasService {
return column;
}
async getDataAggregateBy(param: {
viewId: string;
query?: any;
aggregateColumnName: string;
aggregateFunction: string;
groupByColumnName?: string;
ignoreFilters?: boolean;
sort?: {
column_name: string;
direction: 'asc' | 'desc';
};
}) {
const { viewId, query = {} } = param;
const view = await View.get(viewId);
const source = await Source.get(view.source_id);
const baseModel = await Model.getBaseModelSQL({
id: view.fk_model_id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
});
const data = await baseModel.groupByAndAggregate(
param.aggregateColumnName,
param.aggregateFunction,
{
groupByColumnName: param.groupByColumnName,
sortBy: param.sort,
...query,
},
);
return new PagedResponseImpl(data, {
...query,
});
}
}

84
packages/nocodb/src/services/public-datas.service.ts

@ -5,7 +5,9 @@ import { ErrorMessages, UITypes, ViewTypes } from 'nocodb-sdk';
import slash from 'slash';
import { nocoExecute } from 'nc-help';
import dayjs from 'dayjs';
import type { LinkToAnotherRecordColumn } from '~/models';
import { CalendarRange } from '~/models';
import { NcError } from '~/helpers/catchError';
import getAst from '~/helpers/getAst';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
@ -36,7 +38,8 @@ export class PublicDatasService {
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP
view.type !== ViewTypes.MAP &&
view.type !== ViewTypes.CALENDAR
) {
NcError.notFound('Not found');
}
@ -73,10 +76,17 @@ export class PublicDatasService {
let data = [];
let count = 0;
let option = {};
if (view && view.type === ViewTypes.CALENDAR) {
option = {
ignorePagination: true,
};
}
try {
data = await nocoExecute(
ast,
await baseModel.list(listArgs),
await baseModel.list(listArgs, option),
{},
listArgs,
);
@ -89,6 +99,70 @@ export class PublicDatasService {
return new PagedResponseImpl(data, { ...param.query, count });
}
async getCalendarRecordCount(param: {
sharedViewUuid: string;
password?: string;
query: any;
}) {
const { sharedViewUuid, password, query = {} } = param;
const view = await View.getByUUID(sharedViewUuid);
if (!view) NcError.notFound('Not found');
if (view.password && view.password !== password) {
return NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
}
if (view.type !== ViewTypes.CALENDAR)
NcError.badRequest('View is not a calendar view');
const { ranges } = await CalendarRange.read(view.id);
const model = await Model.getByIdOrName({
id: view.fk_model_id,
});
const columns = await model.getColumns();
const data: any = await this.dataList({
sharedViewUuid,
password,
query,
});
if (!data) NcError.notFound('Data not found');
const dates: Array<string> = [];
ranges.forEach((range: any) => {
data.list.forEach((date) => {
const from =
date[columns.find((c) => c.id === range.fk_from_column_id).title];
let to;
if (range.fk_to_column_id) {
to = date[columns.find((c) => c.id === range.fk_to_column_id).title];
}
if (from && to) {
const fromDt = dayjs(from);
const toDt = dayjs(to);
let current = fromDt;
while (current.isSameOrBefore(toDt)) {
dates.push(current.format('YYYY-MM-DD HH:mm:ssZ'));
current = current.add(1, 'day');
}
} else if (from) {
dates.push(dayjs(from).format('YYYY-MM-DD HH:mm:ssZ'));
}
});
});
return dates;
}
// todo: Handle the error case where view doesnt belong to model
async groupedDataList(param: {
sharedViewUuid: string;
@ -495,7 +569,8 @@ export class PublicDatasService {
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.CALENDAR
) {
NcError.notFound('Not found');
}
@ -569,7 +644,8 @@ export class PublicDatasService {
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.CALENDAR
) {
NcError.notFound('Not found');
}

2
packages/nocodb/src/utils/acl.ts

@ -70,6 +70,7 @@ const permissionScopes = {
'galleryViewGet',
'kanbanViewGet',
'gridViewUpdate',
'calendarViewGet',
'groupedDataList',
'mmList',
'hmList',
@ -165,6 +166,7 @@ const rolePermissions:
galleryViewGet: true,
kanbanViewGet: true,
groupedDataList: true,
calendarViewGet: true,
mmList: true,
hmList: true,

9
packages/nocodb/src/utils/globals.ts

@ -21,6 +21,9 @@ export enum MetaTable {
FORM_VIEW_COLUMNS = 'nc_form_view_columns_v2',
GALLERY_VIEW = 'nc_gallery_view_v2',
GALLERY_VIEW_COLUMNS = 'nc_gallery_view_columns_v2',
CALENDAR_VIEW = 'nc_calendar_view_v2',
CALENDAR_VIEW_COLUMNS = 'nc_calendar_view_columns_v2',
CALENDAR_VIEW_RANGE = 'nc_calendar_view_range_v2',
GRID_VIEW = 'nc_grid_view_v2',
GRID_VIEW_COLUMNS = 'nc_grid_view_columns_v2',
KANBAN_VIEW = 'nc_kanban_view_v2',
@ -64,6 +67,9 @@ export const orderedMetaTables = [
MetaTable.MAP_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW_COLUMNS,
MetaTable.KANBAN_VIEW,
MetaTable.CALENDAR_VIEW,
MetaTable.CALENDAR_VIEW_COLUMNS,
MetaTable.CALENDAR_VIEW_RANGE,
MetaTable.GRID_VIEW_COLUMNS,
MetaTable.GRID_VIEW,
MetaTable.GALLERY_VIEW_COLUMNS,
@ -138,6 +144,9 @@ export enum CacheScope {
GRID_VIEW = 'gridView',
GRID_VIEW_COLUMN = 'gridViewColumn',
KANBAN_VIEW = 'kanbanView',
CALENDAR_VIEW = 'calendarView',
CALENDAR_VIEW_COLUMN = 'calendarViewColumn',
CALENDAR_VIEW_RANGE = 'calendarViewRange',
MAP_VIEW = 'mapView',
MAP_VIEW_COLUMN = 'mapViewColumn',
KANBAN_VIEW_COLUMN = 'kanbanViewColumn',

11
packages/nocodb/tests/unit/factory/view.ts

@ -9,10 +9,15 @@ const createView = async (
title,
table,
type,
range,
}: {
title: string;
table: Model;
type: ViewTypes;
range?: {
fk_from_column_id?: string;
fk_to_column_id?: string;
};
},
) => {
const viewTypeStr = (type) => {
@ -25,6 +30,8 @@ const createView = async (
return 'grids';
case ViewTypes.KANBAN:
return 'kanbans';
case ViewTypes.CALENDAR:
return 'calendars';
default:
throw new Error('Invalid view type');
}
@ -36,16 +43,16 @@ const createView = async (
.send({
title,
type,
...(range?.fk_from_column_id ? { calendar_range: [range] } : {}),
});
if (response.status !== 200) {
throw new Error('createView', response.body.message);
}
const view = (await View.getByTitleOrId({
return (await View.getByTitleOrId({
fk_model_id: table.id,
titleOrId: title,
})) as View;
return view;
};
const getView = async (

312
packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

@ -43,9 +43,12 @@ let sakilaProject: Base;
// models
let customerTable: Model;
let filmTable: Model;
let rentalTable: Model;
// columns
let customerColumns;
let filmColumns;
let rentalColumns;
// views
let customerGridView: View;
let customerGalleryView: View;
@ -53,6 +56,9 @@ let customerFormView: View;
// use film table because it has single select field
let filmKanbanView: View;
// Use rental table because it has a date field
let rentalCalendarView: View;
const testGetViewRowList = async (view: View) => {
const response = await request(context.app)
.get(
@ -95,6 +101,21 @@ const testGetViewRowListKanban = async (view: View) => {
.and.to.be.a('number');
};
const testGetViewListCalendar = async (view: View) => {
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${rentalTable.id}/views/${view.id}`,
)
.set('xc-auth', context.token)
.expect(200);
const pageInfo = response.body.pageInfo;
if (pageInfo.totalRows !== 16044 && response.body.list.length !== 16044) {
throw new Error('Calendar View row list is not correct');
}
};
function viewRowStaticTests() {
before(async function () {
console.time('#### viewRowTests');
@ -132,6 +153,24 @@ function viewRowStaticTests() {
table: filmTable,
type: ViewTypes.KANBAN,
});
rentalTable = await getTable({
base: sakilaProject,
name: 'rental',
});
rentalColumns = await rentalTable.getColumns();
rentalCalendarView = await createView(context, {
title: 'Rental Calendar',
table: rentalTable,
type: ViewTypes.CALENDAR,
range: {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
},
});
console.timeEnd('#### viewRowTests');
});
@ -148,6 +187,10 @@ function viewRowStaticTests() {
await testGetViewRowList(customerGridView);
});
it('Get view row list Calendar', async () => {
await testGetViewListCalendar(rentalCalendarView);
});
const testGetViewDataListWithRequiredColumns = async (view: View) => {
const requiredColumns = customerColumns
.filter((_, index) => index < 3)
@ -457,6 +500,23 @@ function viewRowTests() {
table: filmTable,
type: ViewTypes.KANBAN,
});
rentalTable = await getTable({
base: sakilaProject,
name: 'rental',
});
rentalColumns = await rentalTable.getColumns();
rentalCalendarView = await createView(context, {
title: 'Rental Calendar',
table: rentalTable,
type: ViewTypes.CALENDAR,
range: {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
},
});
console.timeEnd('#### viewRowTests');
});
@ -554,6 +614,10 @@ function viewRowTests() {
await testGetViewDataListWithRequiredColumnsAndFilter(ViewTypes.GRID);
});
it('Get nested sorted filtered table data list with a lookup column Calendar', async function () {
await testGetViewDataListWithRequiredColumnsAndFilter(ViewTypes.CALENDAR);
});
const testGetNestedSortedFilteredTableDataListWithLookupColumn = async (
viewType: ViewTypes,
) => {
@ -657,6 +721,7 @@ function viewRowTests() {
const testCreateRowView = async (viewType: ViewTypes) => {
const table = await createTable(context, base);
const view = await createView(context, {
title: 'View',
table: table,
@ -691,8 +756,13 @@ function viewRowTests() {
await testCreateRowView(ViewTypes.KANBAN);
});
it('Create table row Calendar', async function () {
await testCreateRowView(ViewTypes.CALENDAR);
});
const testCreateRowViewWithWrongView = async (viewType: ViewTypes) => {
const table = await createTable(context, base);
const nonRelatedView = await createView(context, {
title: 'View',
table: customerTable,
@ -726,16 +796,22 @@ function viewRowTests() {
await testCreateRowViewWithWrongView(ViewTypes.KANBAN);
});
it('Create table row wrong calendar id', async function () {
await testCreateRowViewWithWrongView(ViewTypes.CALENDAR);
});
// todo: Test that all the columns needed to be shown in the view are returned
const testFindOneSortedDataWithRequiredColumns = async (
viewType: ViewTypes,
) => {
const table = viewType === ViewTypes.CALENDAR ? rentalTable : customerTable;
const view = await createView(context, {
title: 'View',
table: customerTable,
table: table,
type: viewType,
});
const firstNameColumn = customerColumns.find(
(col) => col.title === 'FirstName',
);
@ -954,6 +1030,9 @@ function viewRowTests() {
it('Groupby desc sorted and with rollup view data list with required columns GALLERY', async function () {
await testGroupDescSorted(ViewTypes.GALLERY);
});
it('Groupby desc sorted and with rollup view data list with required columns CALENDAR', async function () {
await testGroupDescSorted(ViewTypes.CALENDAR);
});
const testGroupWithOffset = async (viewType: ViewTypes) => {
const view = await createView(context, {
@ -1006,41 +1085,81 @@ function viewRowTests() {
it('Groupby desc sorted and with rollup view data list with required columns GRID', async function () {
await testGroupWithOffset(ViewTypes.GRID);
});
it('Groupby desc sorted and with rollup view data list with required columns CALENDAR', async function () {
await testGroupWithOffset(ViewTypes.CALENDAR);
});
const testCount = async (viewType: ViewTypes) => {
let calendar_range = {};
let table;
if (viewType === ViewTypes.CALENDAR) {
table = rentalTable;
calendar_range = {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
};
} else {
table = customerTable;
}
const view = await createView(context, {
title: 'View',
table: customerTable,
table: table,
type: viewType,
range: calendar_range,
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/count`,
`/api/v1/db/data/noco/${sakilaProject.id}/${table.id}/views/${view.id}/count`,
)
.set('xc-auth', context.token)
.expect(200);
if (parseInt(response.body.count) !== 599) {
throw new Error('Wrong count');
if (viewType === ViewTypes.CALENDAR) {
if (parseInt(response.body.count) !== 16044) {
throw new Error('Wrong count');
}
} else {
if (parseInt(response.body.count) !== 599) {
throw new Error('Wrong count');
}
}
};
it('Count view data list with required columns', async function () {
await testCount(ViewTypes.GRID);
await testCount(ViewTypes.FORM);
await testCount(ViewTypes.GALLERY);
await testCount(ViewTypes.CALENDAR);
});
const testReadViewRow = async (viewType: ViewTypes) => {
let table;
let calendar_range = {};
let Id = 'CustomerId';
if (viewType === ViewTypes.CALENDAR) {
table = rentalTable;
calendar_range = {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
};
Id = 'RentalId';
} else {
table = customerTable;
}
const view = await createView(context, {
title: 'View',
table: customerTable,
table: table,
type: viewType,
range: calendar_range,
});
const listResponse = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}`,
`/api/v1/db/data/noco/${sakilaProject.id}/${table.id}/views/${view.id}`,
)
.set('xc-auth', context.token)
.expect(200);
@ -1049,13 +1168,13 @@ function viewRowTests() {
const readResponse = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/${row['CustomerId']}`,
`/api/v1/db/data/noco/${sakilaProject.id}/${table.id}/views/${view.id}/${row[Id]}`,
)
.set('xc-auth', context.token)
.expect(200);
if (
row['CustomerId'] !== readResponse.body['CustomerId'] ||
row[Id] !== readResponse.body[Id] ||
row['FirstName'] !== readResponse.body['FirstName']
) {
throw new Error('Wrong read');
@ -1065,15 +1184,31 @@ function viewRowTests() {
await testReadViewRow(ViewTypes.GALLERY);
await testReadViewRow(ViewTypes.FORM);
await testReadViewRow(ViewTypes.GRID);
await testReadViewRow(ViewTypes.CALENDAR);
});
const testUpdateViewRow = async (viewType: ViewTypes) => {
const table = await createTable(context, base);
const row = await createRow(context, { base, table });
let calendar_range = {};
if (viewType === ViewTypes.CALENDAR) {
const column = await createColumn(context, table, {
title: 'RentalDate',
column_name: 'rental_date',
uidt: UITypes.Date,
});
calendar_range = {
fk_from_column_id: column.id,
};
}
const view = await createView(context, {
title: 'View',
table: table,
type: viewType,
range: calendar_range,
});
const updateResponse = await request(context.app)
@ -1099,6 +1234,9 @@ function viewRowTests() {
it('Update view row FORM', async function () {
await testUpdateViewRow(ViewTypes.FORM);
});
it('Update view row CALENDAR', async function () {
await testUpdateViewRow(ViewTypes.CALENDAR);
});
const testUpdateViewRowWithValidationAndInvalidData = async (
viewType: ViewTypes,
@ -1112,10 +1250,25 @@ function viewRowTests() {
validate: true,
},
});
let calendar_range = {};
if (viewType === ViewTypes.CALENDAR) {
const column = await createColumn(context, table, {
title: 'RentalDate',
column_name: 'rental_date',
uidt: UITypes.Date,
});
calendar_range = {
fk_from_column_id: column.id,
};
}
const view = await createView(context, {
title: 'View',
table: table,
type: viewType,
range: calendar_range,
});
const row = await createRow(context, { base, table });
@ -1139,7 +1292,9 @@ function viewRowTests() {
it('Update view row with validation and invalid data FORM', async function () {
await testUpdateViewRowWithValidationAndInvalidData(ViewTypes.FORM);
});
it('Update view row with validation and invalid data CALENDAR', async function () {
await testUpdateViewRowWithValidationAndInvalidData(ViewTypes.CALENDAR);
});
// todo: Test webhooks of before and after update
// todo: Test with form view
@ -1155,10 +1310,25 @@ function viewRowTests() {
validate: true,
},
});
let calendar_range = {};
if (viewType === ViewTypes.CALENDAR) {
const column = await createColumn(context, table, {
title: 'RentalDate',
column_name: 'rental_date',
uidt: UITypes.Date,
});
calendar_range = {
fk_from_column_id: column.id,
};
}
const view = await createView(context, {
title: 'View',
table: table,
type: viewType,
range: calendar_range,
});
const row = await createRow(context, { base, table });
@ -1190,14 +1360,31 @@ function viewRowTests() {
it('Update view row with validation and valid data FORM', async function () {
await testUpdateViewRowWithValidationAndValidData(ViewTypes.FORM);
});
it('Update view row with validation and valid data CALENDAR', async function () {
await testUpdateViewRowWithValidationAndValidData(ViewTypes.CALENDAR);
});
const testDeleteViewRow = async (viewType: ViewTypes) => {
const table = await createTable(context, base);
let calendar_range = {};
if (viewType === ViewTypes.CALENDAR) {
const range = await createColumn(context, table, {
title: 'RentalDate',
column_name: 'rental_date',
uidt: UITypes.Date,
});
calendar_range = {
fk_from_column_id: range.id,
};
}
const row = await createRow(context, { base, table });
const view = await createView(context, {
title: 'View',
table: table,
type: viewType,
range: calendar_range,
});
await request(context.app)
@ -1222,6 +1409,9 @@ function viewRowTests() {
it('Delete view row FORM', async function () {
await testDeleteViewRow(ViewTypes.FORM);
});
it('Delete view row CALENDAR', async function () {
await testDeleteViewRow(ViewTypes.CALENDAR);
});
const testDeleteViewRowWithForeignKeyConstraint = async (
viewType: ViewTypes,
@ -1275,21 +1465,40 @@ function viewRowTests() {
it('Delete view row with ltar foreign key constraint FORM', async function () {
await testDeleteViewRowWithForeignKeyConstraint(ViewTypes.FORM);
});
it('Delete view row with ltar foreign key constraint Calendar', async function () {
await testDeleteViewRowWithForeignKeyConstraint(ViewTypes.CALENDAR);
});
const testViewRowExists = async (viewType: ViewTypes) => {
let table;
let calendar_range = {};
let colTitle;
if (viewType === ViewTypes.CALENDAR) {
colTitle = 'RentalId';
table = rentalTable;
calendar_range = {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalId').id,
};
} else {
table = customerTable;
colTitle = 'CustomerId';
}
const row = await getOneRow(context, {
base: sakilaProject,
table: customerTable,
table: table,
});
const view = await createView(context, {
title: 'View',
table: customerTable,
table: table,
type: viewType,
range: calendar_range,
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/${row['CustomerId']}/exist`,
`/api/v1/db/data/noco/${sakilaProject.id}/${table.id}/views/${view.id}/${row[colTitle]}/exist`,
)
.set('xc-auth', context.token)
.expect(200);
@ -1302,17 +1511,34 @@ function viewRowTests() {
await testViewRowExists(ViewTypes.GALLERY);
await testViewRowExists(ViewTypes.GRID);
await testViewRowExists(ViewTypes.FORM);
await testViewRowExists(ViewTypes.CALENDAR);
});
const testViewRowNotExists = async (viewType: ViewTypes) => {
let calendar_range = {};
if (viewType === ViewTypes.CALENDAR) {
calendar_range = {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
};
}
let table;
if (viewType === ViewTypes.CALENDAR) {
table = rentalTable;
} else {
table = customerTable;
}
const view = await createView(context, {
title: 'View',
table: customerTable,
table: table,
type: viewType,
range: calendar_range,
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/999999/exist`,
`/api/v1/db/data/noco/${sakilaProject.id}/${table.id}/views/${view.id}/999999/exist`,
)
.set('xc-auth', context.token)
.expect(200);
@ -1325,6 +1551,62 @@ function viewRowTests() {
await testViewRowNotExists(ViewTypes.GALLERY);
await testViewRowNotExists(ViewTypes.GRID);
await testViewRowNotExists(ViewTypes.FORM);
await testViewRowNotExists(ViewTypes.CALENDAR);
});
const testCountDatesByRange = async (viewType: ViewTypes) => {
let calendar_range = {};
let expectStatus = 400;
if (viewType === ViewTypes.CALENDAR) {
calendar_range = {
fk_from_column_id: rentalColumns.find((c) => c.title === 'RentalDate')
.id,
};
expectStatus = 200;
}
const view = await createView(context, {
title: 'View',
table: rentalTable,
type: viewType,
range: calendar_range,
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${rentalTable.id}/views/${view.id}/countByDate/`,
)
.set('xc-auth', context.token)
.expect(expectStatus);
if (expectStatus === 200 && response.body.length !== 25) {
throw new Error('Wrong count');
} else if (
expectStatus === 400 &&
response.body.msg !== 'View is not a calendar view'
) {
throw new Error('Wrong error message');
}
};
it('Count dates by range Calendar', async () => {
await testCountDatesByRange(ViewTypes.CALENDAR);
});
it('Count dates by range GRID', async () => {
await testCountDatesByRange(ViewTypes.GRID);
});
it('Count dates by range KANBAN', async () => {
await testCountDatesByRange(ViewTypes.KANBAN);
});
it('Count dates by range FORM', async () => {
await testCountDatesByRange(ViewTypes.FORM);
});
it('Count dates by range GALLERY', async () => {
await testCountDatesByRange(ViewTypes.GALLERY);
});
it('Export csv GRID', async function () {

26
tests/playwright/pages/Dashboard/Calendar/CalendarDayDate.ts

@ -0,0 +1,26 @@
import BasePage from '../../Base';
import { CalendarPage } from './index';
import { expect } from '@playwright/test';
export class CalendarDayDatePage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-day-view');
}
async verifyRecord(data: { records: string[] }) {
const records = await this.get().getByTestId('nc-calendar-day-record-card');
await expect(records).toHaveCount(data.records.length);
for (let i = 0; i < data.records.length; i++) {
await expect(records.nth(i)).toContainText(data.records[i]);
}
}
}

52
tests/playwright/pages/Dashboard/Calendar/CalendarDayDateTime.ts

@ -0,0 +1,52 @@
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarDayDateTimePage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-day-view');
}
getRecordContainer() {
return this.get().getByTestId('nc-calendar-day-record-container');
}
async dragAndDrop({ record, hourIndex }: { record: string; hourIndex: number }) {
const recordContainer = this.getRecordContainer();
const recordCard = recordContainer.getByTestId(`nc-calendar-day-record-${record}`);
const toDay = this.get().getByTestId('nc-calendar-day-hour').nth(hourIndex);
const cord = await toDay.boundingBox();
await recordCard.scrollIntoViewIfNeeded();
await recordCard.hover();
await this.rootPage.mouse.down();
// Bit Flaky
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2, {
steps: 10,
});
// Bit Flaky
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.up();
}
async selectHour({ hourIndex }: { hourIndex: number }) {
const hour = this.get().getByTestId('nc-calendar-day-hour').nth(hourIndex);
await hour.click({
force: true,
position: {
x: 0,
y: 1,
},
});
}
}

51
tests/playwright/pages/Dashboard/Calendar/CalendarMonth.ts

@ -0,0 +1,51 @@
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarMonthPage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-month-view');
}
getRecordContainer() {
return this.get().getByTestId('nc-calendar-month-record-container');
}
async dragAndDrop({ record, to }: { record: string; to: { rowIndex: number; columnIndex: number } }) {
const recordContainer = this.getRecordContainer();
const recordCard = recordContainer.getByTestId(`nc-calendar-month-record-${record}`);
const toDay = this.get()
.getByTestId('nc-calendar-month-week')
.nth(to.rowIndex)
.getByTestId('nc-calendar-month-day')
.nth(to.columnIndex);
const cord = await toDay.boundingBox();
await recordCard.hover();
await this.rootPage.mouse.down();
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2, { steps: 10 });
await this.rootPage.mouse.up();
}
async selectDate({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }) {
const week = this.get().getByTestId('nc-calendar-month-week');
const day = week.nth(rowIndex).getByTestId('nc-calendar-month-day').nth(columnIndex);
await day.click({
force: true,
position: {
x: 0,
y: 1,
},
});
}
}

43
tests/playwright/pages/Dashboard/Calendar/CalendarSideMenu.ts

@ -0,0 +1,43 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarSideMenuPage extends BasePage {
readonly parent: CalendarPage;
readonly new_record_btn: Locator;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
this.new_record_btn = this.get().getByTestId('nc-calendar-side-menu-new-btn');
}
get() {
return this.rootPage.getByTestId('nc-calendar-side-menu');
}
async updateFilter({ filter }: { filter: string }) {
const filterInput = this.get().getByTestId('nc-calendar-sidebar-filter');
await filterInput.click();
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${filter}"`).click();
}
async searchRecord({ query }: { query: string }) {
const searchInput = this.get().getByTestId('nc-calendar-sidebar-search');
await searchInput.fill(query);
}
async verifySideBarRecords({ records }: { records: string[] }) {
const sideBar = this.get().getByTestId('nc-calendar-side-menu-list');
const sideBarRecords = await sideBar.getByTestId('nc-sidebar-record-card');
await expect(sideBarRecords).toHaveCount(records.length);
for (let i = 0; i < records.length; i++) {
await expect(sideBarRecords.nth(i)).toContainText(records[i]);
}
}
}

63
tests/playwright/pages/Dashboard/Calendar/CalendarTopBar.ts

@ -0,0 +1,63 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarTopbarPage extends BasePage {
readonly parent: CalendarPage;
readonly today_btn: Locator;
readonly prev_btn: Locator;
readonly next_btn: Locator;
readonly side_bar_btn: Locator;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
this.next_btn = this.get().getByTestId('nc-calendar-next-btn');
this.prev_btn = this.get().getByTestId('nc-calendar-prev-btn');
this.today_btn = this.get().getByTestId('nc-calendar-today-btn');
this.side_bar_btn = this.get().getByTestId('nc-calendar-side-bar-btn');
}
get() {
return this.rootPage.getByTestId('nc-calendar-topbar');
}
async getActiveDate() {
return this.get().getByTestId('nc-calendar-active-date').textContent();
}
async verifyActiveCalendarView({ view }: { view: string }) {
const activeView = this.get().getByTestId('nc-active-calendar-view');
await expect(activeView).toContainText(view);
}
async clickPrev() {
await this.prev_btn.click();
}
async clickNext() {
await this.next_btn.click();
}
async clickToday() {
await this.today_btn.click();
}
async moveToDate({ date, action }: { date: string; action: 'prev' | 'next' }) {
while ((await this.getActiveDate()) !== date) {
if (action === 'prev') {
await this.clickPrev();
} else {
await this.clickNext();
}
}
}
async toggleSideBar() {
await this.side_bar_btn.click();
await this.rootPage.waitForTimeout(500);
}
}

45
tests/playwright/pages/Dashboard/Calendar/CalendarWeekDate.ts

@ -0,0 +1,45 @@
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarWeekDatePage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-week-view');
}
getRecordContainer() {
return this.get().getByTestId('nc-calendar-week-record-container');
}
async dragAndDrop({ record, dayIndex }: { record: string; dayIndex: number }) {
const recordContainer = this.getRecordContainer();
const recordCard = recordContainer.getByTestId(`nc-calendar-week-record-${record}`);
const toDay = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex);
const cord = await toDay.boundingBox();
await recordCard.hover();
await this.rootPage.mouse.down();
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2);
await this.rootPage.mouse.up();
}
async selectDay({ dayIndex }: { dayIndex: number }) {
const day = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex);
await day.click({
force: true,
position: {
x: 0,
y: 1,
},
});
}
}

59
tests/playwright/pages/Dashboard/Calendar/CalendarWeekDateTime.ts

@ -0,0 +1,59 @@
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarWeekDateTimePage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-week-view');
}
getRecordContainer() {
return this.get().getByTestId('nc-calendar-week-record-container');
}
async dragAndDrop({
record,
to,
}: {
record: string;
to: {
dayIndex: number;
hourIndex: number;
};
}) {
const recordContainer = this.getRecordContainer();
const recordCard = recordContainer.getByTestId(`nc-calendar-week-record-${record}`);
const toDay = this.get()
.getByTestId('nc-calendar-week-day')
.nth(to.dayIndex)
.getByTestId('nc-calendar-week-hour')
.nth(to.hourIndex);
const cord = await toDay.boundingBox();
await recordCard.hover();
await this.rootPage.mouse.down();
await this.rootPage.waitForTimeout(500);
await this.rootPage.mouse.move(cord.x + cord.width / 2, cord.y + cord.height / 2);
await this.rootPage.mouse.up();
}
async selectHour({ hourIndex, dayIndex }: { dayIndex: number; hourIndex: number }) {
const day = this.get().getByTestId('nc-calendar-week-day').nth(dayIndex);
const hour = day.getByTestId('nc-calendar-week-hour').nth(hourIndex);
await hour.click({
force: true,
position: {
x: -1,
y: -1,
},
});
}
}

26
tests/playwright/pages/Dashboard/Calendar/CalendarYear.ts

@ -0,0 +1,26 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { CalendarPage } from './index';
export class CalendarYearPage extends BasePage {
readonly parent: CalendarPage;
constructor(parent: CalendarPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-calendar-year-view');
}
getMonth({ index }: { index: number }) {
return this.get().getByTestId('nc-calendar-year-view-month-selector').nth(index);
}
async selectDate({ monthIndex, dayIndex }: { monthIndex: number; dayIndex: number }) {
const month = this.getMonth({ index: monthIndex });
const day = month.getByTestId('nc-calendar-date').nth(dayIndex);
await day.click({ force: true });
}
}

63
tests/playwright/pages/Dashboard/Calendar/index.ts

@ -0,0 +1,63 @@
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
import { expect } from '@playwright/test';
import { TopbarPage } from '../common/Topbar';
import { CalendarTopbarPage } from './CalendarTopBar';
import { CalendarSideMenuPage } from './CalendarSideMenu';
import { CalendarMonthPage } from './CalendarMonth';
import { CalendarYearPage } from './CalendarYear';
import { CalendarDayDateTimePage } from './CalendarDayDateTime';
import { CalendarWeekDateTimePage } from './CalendarWeekDateTime';
import { CalendarDayDatePage } from './CalendarDayDate';
import { CalendarWeekDatePage } from './CalendarWeekDate';
export class CalendarPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
readonly topbar: TopbarPage;
readonly calendarTopbar: CalendarTopbarPage;
readonly sideMenu: CalendarSideMenuPage;
readonly calendarMonth: CalendarMonthPage;
readonly calendarYear: CalendarYearPage;
readonly calendarDayDateTime: CalendarDayDateTimePage;
readonly calendarWeekDateTime: CalendarWeekDateTimePage;
readonly calendarDayDate: CalendarDayDatePage;
readonly calendarWeekDate: CalendarWeekDatePage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.toolbar = new ToolbarPage(this);
this.topbar = new TopbarPage(this);
this.calendarTopbar = new CalendarTopbarPage(this);
this.sideMenu = new CalendarSideMenuPage(this);
this.calendarMonth = new CalendarMonthPage(this);
this.calendarYear = new CalendarYearPage(this);
this.calendarDayDateTime = new CalendarDayDateTimePage(this);
this.calendarWeekDateTime = new CalendarWeekDateTimePage(this);
this.calendarDayDate = new CalendarDayDatePage(this);
this.calendarWeekDate = new CalendarWeekDatePage(this);
}
get() {
return this.dashboard.rootPage.getByTestId('nc-calendar-wrapper');
}
async verifySideBarClosed() {
const sideBar = this.get().getByTestId('nc-calendar-side-menu');
const classList = await sideBar.evaluate(el => [...el.classList]);
expect(classList).not.toContain('nc-calendar-side-menu-open');
}
async verifySideBarOpen() {
const sideBar = this.get().getByTestId('nc-calendar-side-menu');
const classList = await sideBar.evaluate(el => [...el.classList]);
expect(classList).toContain('nc-calendar-side-menu-open');
}
async waitLoading() {
await this.rootPage.waitForTimeout(2000);
}
}

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

@ -28,10 +28,6 @@ export class ColumnPageObject extends BasePage {
return this.grid.get().locator(`.nc-grid-header > th`).nth(index);
}
private getColumnHeader(title: string) {
return this.grid.get().locator(`th[data-title="${title}"]`).first();
}
async clickColumnHeader({ title }: { title: string }) {
await this.getColumnHeader(title).click();
}
@ -220,8 +216,12 @@ export class ColumnPageObject extends BasePage {
await this.get().locator('.nc-column-name-input').fill(title);
}
async selectType({ type }: { type: string }) {
await this.get().locator('.ant-select-selector > .ant-select-selection-item').click();
async selectType({ type, first }: { type: string; first?: boolean }) {
if (first) {
await this.get().locator('.ant-select-selector > .ant-select-selection-item').first().click();
} else {
await this.get().locator('.ant-select-selector > .ant-select-selection-item').click();
}
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').fill(type);
@ -275,7 +275,6 @@ export class ColumnPageObject extends BasePage {
await this.rootPage.locator('.ant-modal.active').waitFor({ state: 'hidden' });
}
// opening edit modal in table header double click
// or in the dropdown edit click
async openEdit({
title,
@ -284,6 +283,7 @@ export class ColumnPageObject extends BasePage {
format,
dateFormat = '',
timeFormat = '',
selectType = false,
}: {
title: string;
type?: string;
@ -291,6 +291,7 @@ export class ColumnPageObject extends BasePage {
format?: string;
dateFormat?: string;
timeFormat?: string;
selectType?: boolean;
}) {
// when clicked on the dropdown cell header
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').scrollIntoViewIfNeeded();
@ -299,6 +300,10 @@ export class ColumnPageObject extends BasePage {
await this.get().waitFor({ state: 'visible' });
if (selectType) {
await this.selectType({ type, first: true });
}
switch (type) {
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
@ -329,6 +334,8 @@ export class ColumnPageObject extends BasePage {
}
}
// opening edit modal in table header double click
async editMenuShowMore() {
await this.rootPage.locator('.nc-more-options').click();
}
@ -480,4 +487,8 @@ export class ColumnPageObject extends BasePage {
const cell = this.rootPage.locator(`th[data-title="${title}"]`);
return await cell.evaluate(el => el.getBoundingClientRect().width);
}
private getColumnHeader(title: string) {
return this.grid.get().locator(`th[data-title="${title}"]`).first();
}
}

28
tests/playwright/pages/Dashboard/Sidebar/index.ts

@ -63,7 +63,15 @@ export class SidebarPage extends BasePage {
await this.rootPage.waitForTimeout(1000);
}
async createProject({ title, type }: { title: string; type: ProjectTypes }) {
async createProject({
title,
type,
networkValidation = true,
}: {
title: string;
type: ProjectTypes;
networkValidation?: boolean;
}) {
await this.createProjectBtn.click();
if (type === ProjectTypes.DOCUMENTATION) {
await this.dashboard.get().locator('.nc-create-base-btn-docs').click();
@ -71,11 +79,15 @@ export class SidebarPage extends BasePage {
await this.dashboard.get().locator('.nc-metadb-base-name').clear();
await this.dashboard.get().locator('.nc-metadb-base-name').fill(title);
await this.waitForResponse({
uiAction: () => this.dashboard.get().getByTestId('docs-create-proj-dlg-create-btn').click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: `/api/v1/db/meta/projects/`,
});
if (networkValidation) {
await this.waitForResponse({
uiAction: () => this.dashboard.get().getByTestId('docs-create-proj-dlg-create-btn').click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: `/api/v1/db/meta/projects/`,
});
} else {
await this.dashboard.get().getByTestId('docs-create-proj-dlg-create-btn').click();
}
if (type === ProjectTypes.DOCUMENTATION) {
await this.dashboard.docs.pagesList.waitForOpen({ title });
@ -100,6 +112,10 @@ export class SidebarPage extends BasePage {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-kanban');
} else if (type === ViewTypes.GALLERY) {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-gallery');
} else if (type === ViewTypes.CALENDAR) {
// TODO: Remove this once the easter egg is removed
await this.rootPage.waitForTimeout(4500);
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-calendar');
}
await this.rootPage.waitForTimeout(750);

19
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -12,6 +12,7 @@ export class ViewSidebarPage extends BasePage {
readonly createFormButton: Locator;
readonly createKanbanButton: Locator;
readonly createMapButton: Locator;
readonly createCalendarButton: Locator;
readonly erdButton: Locator;
readonly apiSnippet: Locator;
@ -25,6 +26,7 @@ export class ViewSidebarPage extends BasePage {
this.createGridButton = this.get().locator('.nc-create-grid-view:visible');
this.createFormButton = this.get().locator('.nc-create-form-view:visible');
this.createKanbanButton = this.get().locator('.nc-create-kanban-view:visible');
this.createCalendarButton = this.get().locator('.nc-create-calendar-view:visible');
this.erdButton = this.get().locator('.nc-view-sidebar-erd');
this.apiSnippet = this.get().locator('.nc-view-sidebar-api-snippet');
@ -54,12 +56,6 @@ export class ViewSidebarPage extends BasePage {
await this.rootPage.goto(this.rootPage.url());
}
private async createView({ title, type }: { title: string; type: ViewTypes }) {
await this.rootPage.waitForTimeout(1000);
await this.dashboard.sidebar.createView({ title, type });
}
async createGalleryView({ title }: { title: string }) {
await this.createView({ title, type: ViewTypes.GALLERY });
}
@ -84,6 +80,11 @@ export class ViewSidebarPage extends BasePage {
await this.rootPage.waitForTimeout(1500);
}
async createCalendarView({ title }: { title: string }) {
await this.createView({ title, type: ViewTypes.CALENDAR });
await this.rootPage.waitForTimeout(1500);
}
async createMapView({ title }: { title: string }) {
await this.createView({ title, type: ViewTypes.MAP });
}
@ -203,6 +204,12 @@ export class ViewSidebarPage extends BasePage {
// await expect(this.webhookButton).toHaveCount(count);
}
private async createView({ title, type }: { title: string; type: ViewTypes }) {
await this.rootPage.waitForTimeout(1000);
await this.dashboard.sidebar.createView({ title, type });
}
// async openDeveloperTab({ option }: { option?: string }) {
// await this.get().locator('.nc-tab').nth(1).click();
// if (option === 'ERD') {

44
tests/playwright/pages/Dashboard/common/Toolbar/CalendarRange.ts

@ -0,0 +1,44 @@
import BasePage from '../../../Base';
import { ToolbarPage } from './index';
import { getTextExcludeIconText } from '../../../../tests/utils/general';
import { expect } from '@playwright/test';
export class ToolbarCalendarRangePage extends BasePage {
readonly toolbar: ToolbarPage;
constructor(toolbar: ToolbarPage) {
super(toolbar.rootPage);
this.toolbar = toolbar;
}
get() {
return this.rootPage.getByTestId('nc-calendar-range-menu');
}
async click({ title }: { title: string }) {
await this.get().getByTestId('nc-calendar-range-from-field-select').click();
await this.rootPage.locator('.ant-select-dropdown:visible').locator(`div[title="${title}"]`).click();
}
async newCalendarRange({ fromTitle, toTitle }: { fromTitle: string; toTitle?: string }) {
await this.get().getByTestId(`nc-calendar-range-from-field-select`).click();
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${fromTitle}"`).click();
if (toTitle) {
await this.get().getByTestId(`nc-calendar-range-to-field-select`).click();
await this.rootPage.locator('.ant-select-item-option-content').locator(`:text("${toTitle}")`).click();
}
}
async verifyCalendarRange({ fromTitle, toTitle }: { fromTitle: string; toTitle?: string }) {
const from = await this.get().getByTestId('nc-calendar-range-from-field-select');
const fromFieldText = await getTextExcludeIconText(from);
expect(fromFieldText).toBe(fromTitle);
if (toTitle) {
const to = await this.get().getByTestId('nc-calendar-range-to-field-select');
const toFieldText = await getTextExcludeIconText(to);
expect(toFieldText).toBe(toTitle);
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save