<script lang="ts" setup> import type { ComponentPublicInstance } from '@vue/runtime-core' import { capitalize } from '@vue/runtime-core' import type { Form as AntForm, SelectProps } from 'ant-design-vue' import { type CalendarType, type ColumnType, type FormType, FormulaDataTypes, type GalleryType, type GridType, type KanbanType, type LookupType, type MapType, type SerializedAiViewType, type TableType, stringToViewTypeMap, viewTypeToStringMap, } from 'nocodb-sdk' import { UITypes, ViewTypes } from 'nocodb-sdk' import { AiWizardTabsType } from '#imports' const props = withDefaults(defineProps<Props>(), { selectedViewId: undefined, groupingFieldColumnId: undefined, geoDataFieldColumnId: undefined, calendarRange: undefined, coverImageColumnId: undefined, }) const emits = defineEmits<Emits>() const maxSelectionCount = 100 interface Props { modelValue: boolean type: ViewTypes | 'AI' baseId: string tableId: string title?: string selectedViewId?: string groupingFieldColumnId?: string geoDataFieldColumnId?: string description?: string calendarRange?: Array<{ fk_from_column_id: string fk_to_column_id: string | null // for ee only }> coverImageColumnId?: string } interface Emits { (event: 'update:modelValue', value: boolean): void (event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType | CalendarType): void } interface Form { title: string type: ViewTypes | 'AI' description?: string copy_from_id: string | null // 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 | null // for ee only }> fk_cover_image_col_id: string | null } type AiSuggestedViewType = SerializedAiViewType & { selected?: boolean tab?: AiWizardTabsType } const { metas, getMeta } = useMetas() const workspaceStore = useWorkspace() const { viewsByTable } = storeToRefs(useViewsStore()) const { refreshCommandPalette } = useCommandPalette() const { isFeatureEnabled } = useBetaFeatureToggle() const { selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, tableId, coverImageColumnId, baseId } = toRefs(props) const meta = ref<TableType | undefined>() const inputEl = ref<ComponentPublicInstance>() const aiPromptInputRef = ref<HTMLElement>() const descriptionInputEl = ref<ComponentPublicInstance>() const formValidator = ref<typeof AntForm>() const vModel = useVModel(props, 'modelValue', emits) const { t } = useI18n() const { api } = useApi() 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 || [], fk_cover_image_col_id: null, description: props.description || '', }) const viewSelectFieldOptions = ref<SelectProps['options']>([]) const viewNameRules = [ // name is required { required: true, message: `${t('labels.viewName')} ${t('general.required').toLowerCase()}` }, // name is unique { validator: (_: unknown, v: string) => new Promise((resolve, reject) => { views.value.every((v1) => v1.title !== v) ? resolve(true) : reject(new Error(`View name should be unique`)) }), message: 'View name should be unique', }, ] const groupingFieldColumnRules = [{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` }] const geoDataFieldColumnRules = [{ required: true, message: `${t('general.geoDataField')} ${t('general.required')}` }] const typeAlias = computed( () => ({ [ViewTypes.GRID]: 'grid', [ViewTypes.GALLERY]: 'gallery', [ViewTypes.FORM]: 'form', [ViewTypes.KANBAN]: 'kanban', [ViewTypes.MAP]: 'map', [ViewTypes.CALENDAR]: 'calendar', // Todo: add ai view docs route AI: '', }[props.type]), ) const { aiIntegrationAvailable, aiLoading, aiError, predictViews: _predictViews, createViews } = useNocoAi() const aiMode = ref(false) enum AiStep { init = 'init', pick = 'pick', } const aiModeStep = ref<AiStep | null>(null) const isAIViewCreateMode = computed(() => props.type === 'AI') const calledFunction = ref<string>() const prompt = ref<string>('') const oldPrompt = ref<string>('') const isPromtAlreadyGenerated = ref<boolean>(false) const activeAiTabLocal = ref<AiWizardTabsType>(AiWizardTabsType.AUTO_SUGGESTIONS) const isAiSaving = computed(() => aiLoading.value && calledFunction.value === 'createViews') const activeAiTab = computed({ get: () => { return activeAiTabLocal.value }, set: (value: AiWizardTabsType) => { activeAiTabLocal.value = value aiError.value = '' if (value === AiWizardTabsType.PROMPT) { nextTick(() => { aiPromptInputRef.value?.focus() }) } }, }) const predictedViews = ref<AiSuggestedViewType[]>([]) const activeTabPredictedViews = computed(() => predictedViews.value.filter((t) => t.tab === activeAiTab.value)) const predictHistory = ref<AiSuggestedViewType[]>([]) const activeTabPredictHistory = computed(() => predictHistory.value.filter((t) => t.tab === activeAiTab.value)) const activeTabSelectedViews = computed(() => { return predictedViews.value.filter((v) => !!v.selected && v.tab === activeAiTab.value) }) onBeforeMount(init) watch( () => props.type, (newType) => { form.type = newType }, ) const onAiEnter = async () => { calledFunction.value = 'createViews' if (activeTabSelectedViews.value.length) { try { const data = await createViews(activeTabSelectedViews.value, baseId.value) emits('created', ncIsArray(data) && data.length ? data[0] : undefined) } catch (e) { message.error(e) } finally { await refreshCommandPalette() } vModel.value = false } } async function onSubmit() { if (aiMode.value) { return onAiEnter() } let isValid = null try { isValid = await formValidator.value?.validateFields() } catch (e) { console.error(e) } if (form.title) { form.title = form.title.trim() } if (isValid && form.type) { if (!tableId.value) return try { let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null isViewCreating.value = true switch (form.type) { case ViewTypes.GRID: data = await api.dbView.gridCreate(tableId.value, form) break case ViewTypes.GALLERY: data = await api.dbView.galleryCreate(tableId.value, form) break case ViewTypes.FORM: data = await api.dbView.formCreate(tableId.value, form) break case ViewTypes.KANBAN: data = await api.dbView.kanbanCreate(tableId.value, form) 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) { // View created successfully // message.success(t('msg.toast.createView')) emits('created', data) } } catch (e: any) { message.error(e.message) } finally { await refreshCommandPalette() } vModel.value = false setTimeout(() => { isViewCreating.value = false }, 500) } } /* const addCalendarRange = async () => { form.calendar_range.push({ fk_from_column_id: viewSelectFieldOptions.value[0].value as string, fk_to_column_id: null, }) } */ const isRangeEnabled = computed(() => isFeatureEnabled(FEATURE_FLAG.CALENDAR_VIEW_RANGE)) const enableDescription = ref(false) const removeDescription = () => { form.description = '' enableDescription.value = false } const toggleDescription = () => { if (enableDescription.value) { enableDescription.value = false } else { enableDescription.value = true setTimeout(() => { descriptionInputEl.value?.focus() }, 100) } } const isMetaLoading = ref(false) onMounted(async () => { if (form.copy_from_id) { enableDescription.value = true } if ( [ViewTypes.GALLERY, ViewTypes.KANBAN, ViewTypes.MAP, ViewTypes.CALENDAR].includes(props.type) || aiIntegrationAvailable.value ) { isMetaLoading.value = true try { meta.value = (await getMeta(tableId.value))! if (props.type === ViewTypes.MAP) { viewSelectFieldOptions.value = meta .value!.columns!.filter((el) => el.uidt === UITypes.GeoData) .map((field) => { return { value: field.id, label: field.title, } }) if (geoDataFieldColumnId.value) { // take from the one from copy view form.fk_geo_data_col_id = geoDataFieldColumnId.value } 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 } } // preset the cover image field if (props.type === ViewTypes.GALLERY) { viewSelectFieldOptions.value = [ { value: null, label: 'No Image' }, ...(meta.value.columns || []) .filter((el) => el.uidt === UITypes.Attachment) .map((field) => { return { value: field.id, label: field.title, uidt: field.uidt, } }), ] const lookupColumns = (meta.value.columns || [])?.filter((c) => c.uidt === UITypes.Lookup) const attLookupColumnIds: Set<string> = new Set() const loadLookupMeta = async (originalCol: ColumnType, column: ColumnType, metaId?: string): Promise<void> => { const relationColumn = metaId || meta.value?.id ? metas.value[metaId || meta.value?.id]?.columns?.find( (c: ColumnType) => c.id === (column?.colOptions as LookupType)?.fk_relation_column_id, ) : undefined if (relationColumn?.colOptions?.fk_related_model_id) { await getMeta(relationColumn.colOptions.fk_related_model_id!) const lookupColumn = metas.value[relationColumn.colOptions.fk_related_model_id]?.columns?.find( (c: any) => c.id === (column?.colOptions as LookupType)?.fk_lookup_column_id, ) as ColumnType | undefined if (lookupColumn && isAttachment(lookupColumn)) { attLookupColumnIds.add(originalCol.id) } else if (lookupColumn && lookupColumn?.uidt === UITypes.Lookup) { await loadLookupMeta(originalCol, lookupColumn, relationColumn.colOptions.fk_related_model_id) } } } await Promise.allSettled(lookupColumns.map((col) => loadLookupMeta(col, col))) const lookupAttColumns = lookupColumns .filter((column) => attLookupColumnIds.has(column?.id)) .map((c) => { return { value: c.id, label: c.title, uidt: c.uidt, } }) viewSelectFieldOptions.value = [...viewSelectFieldOptions.value, ...lookupAttColumns] if (coverImageColumnId.value) { form.fk_cover_image_col_id = coverImageColumnId.value } else if (viewSelectFieldOptions.value.length > 1 && !form.copy_from_id) { form.fk_cover_image_col_id = viewSelectFieldOptions.value[1].value as string } else { form.fk_cover_image_col_id = null } } // preset the grouping field column if (props.type === ViewTypes.KANBAN) { viewSelectFieldOptions.value = meta.value .columns!.filter((el) => el.uidt === UITypes.SingleSelect) .map((field) => { return { value: field.id, label: field.title, uidt: field.uidt, } }) 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 (coverImageColumnId.value) { form.fk_cover_image_col_id = coverImageColumnId.value } else if (viewSelectFieldOptions.value.length > 1 && !form.copy_from_id) { form.fk_cover_image_col_id = viewSelectFieldOptions.value[1].value as string } else { form.fk_cover_image_col_id = null } } if (props.type === ViewTypes.CALENDAR) { viewSelectFieldOptions.value = meta .value!.columns!.filter( (el) => [UITypes.DateTime, UITypes.Date, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(el.uidt) || (el.uidt === UITypes.Formula && (el.colOptions as any)?.parsed_tree?.dataType === FormulaDataTypes.DATE), ) .map((field) => { return { value: field.id, label: field.title, uidt: field.uidt, } }) .sort((a, b) => { const priority = { [UITypes.DateTime]: 1, [UITypes.Date]: 2, [UITypes.Formula]: 3, [UITypes.CreatedTime]: 4, [UITypes.LastModifiedTime]: 5, } return (priority[a.uidt] || 6) - (priority[b.uidt] || 6) }) if (viewSelectFieldOptions.value?.length) { // take the first option 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) { console.error(e) } finally { isMetaLoading.value = false } } }) const isCalendarReadonly = (calendarRange?: Array<{ fk_from_column_id: string; fk_to_column_id: string | null }>) => { if (!calendarRange) return false return calendarRange.some((range) => { const column = viewSelectFieldOptions.value?.find((c) => c.value === range?.fk_from_column_id) return !column || ![UITypes.DateTime, UITypes.Date].includes(column.uidt) }) } const isDisabled = computed(() => { return ( [UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime, UITypes.Formula].includes( viewSelectFieldOptions.value.find((f) => f.value === form.calendar_range[0]?.fk_from_column_id)?.uidt, ) && !isRangeEnabled.value ) }) const onValueChange = async () => { form.calendar_range = form.calendar_range.map((range, i) => { if (i === 0) { return { fk_from_column_id: range.fk_from_column_id, fk_to_column_id: null, } } return range }) } const predictViews = async (): Promise<AiSuggestedViewType[]> => { const viewType = !isAIViewCreateMode.value && form.type && viewTypeToStringMap[form.type] ? viewTypeToStringMap[form.type] : undefined return ( await _predictViews( tableId.value, activeTabPredictHistory.value, baseId.value, activeAiTab.value === AiWizardTabsType.PROMPT ? prompt.value : undefined, viewType, ) ) .filter((v: AiSuggestedViewType) => !ncIsArrayIncludes(activeTabPredictedViews.value, v.title, 'title')) .map((v: AiSuggestedViewType) => { return { ...v, tab: activeAiTab.value, selected: false, } }) } const predictMore = async () => { calledFunction.value = 'predictMore' const predictions = await predictViews() if (predictions.length) { predictedViews.value.push(...predictions) predictHistory.value.push(...predictions) } else if (!aiError.value) { message.info(`No more auto suggestions were found for ${meta.value?.title || 'the current table'}`) } } const predictRefresh = async () => { calledFunction.value = 'predictRefresh' const predictions = await predictViews() if (predictions.length) { predictedViews.value = [...predictedViews.value.filter((t) => t.tab !== activeAiTab.value), ...predictions] predictHistory.value.push(...predictions) } else if (!aiError.value) { message.info(`No auto suggestions were found for ${meta.value?.title || 'the current table'}`) } aiModeStep.value = AiStep.pick } const predictFromPrompt = async () => { calledFunction.value = 'predictFromPrompt' const predictions = await predictViews() if (predictions.length) { predictedViews.value = [...predictedViews.value.filter((t) => t.tab !== activeAiTab.value), ...predictions] predictHistory.value.push(...predictions) oldPrompt.value = prompt.value } else if (!aiError.value) { message.info('No suggestions were found with the given prompt. Try again after modifying the prompt.') } aiModeStep.value = AiStep.pick isPromtAlreadyGenerated.value = true } const onToggleTag = (view: AiSuggestedViewType) => { if ( isAiSaving.value || (!view.selected && (activeTabSelectedViews.value.length >= maxSelectionCount || ncIsArrayIncludes(activeTabSelectedViews.value, view.title, 'title'))) ) { return } predictedViews.value = predictedViews.value.map((v) => { if (v.title === view.title && v.tab === activeAiTab.value) { v.selected = !view.selected } return v }) } const onSelectAll = () => { if (activeTabSelectedViews.value.length >= maxSelectionCount) return let count = activeTabSelectedViews.value.length predictedViews.value = predictedViews.value.map((view) => { // Check if the item can be selected if (view.tab === activeAiTab.value && !view.selected && count < maxSelectionCount) { view.selected = true count++ } return view }) } const toggleAiMode = async () => { if (aiMode.value) return formValidator.value?.clearValidate() aiError.value = '' aiMode.value = true aiModeStep.value = AiStep.init predictedViews.value = [] predictHistory.value = [] prompt.value = '' oldPrompt.value = '' isPromtAlreadyGenerated.value = false if (aiIntegrationAvailable.value) { await predictRefresh() } } const disableAiMode = () => { if (isAIViewCreateMode.value) return aiMode.value = false aiModeStep.value = null predictedViews.value = [] predictHistory.value = [] prompt.value = '' oldPrompt.value = '' isPromtAlreadyGenerated.value = false activeAiTab.value = AiWizardTabsType.AUTO_SUGGESTIONS nextTick(() => { inputEl.value?.focus() inputEl.value?.select() }) } const fullAuto = async (e) => { const target = e.target as HTMLElement if ( !aiIntegrationAvailable.value || !isNecessaryColumnsPresent.value || aiLoading.value || aiError.value || target.closest('button, input, .nc-button, textarea') ) { return } if (!aiModeStep.value) { await toggleAiMode() } else if (aiModeStep.value === AiStep.pick && activeTabSelectedViews.value.length === 0) { await onSelectAll() } else if (aiModeStep.value === AiStep.pick && activeTabSelectedViews.value.length > 0) { await onAiEnter() } } const isPredictFromPromptLoading = computed(() => { return aiLoading.value && calledFunction.value === 'predictFromPrompt' }) const handleNavigateToIntegrations = () => { vModel.value = false workspaceStore.navigateToIntegrations(undefined, undefined, { categories: 'ai', }) } const handleRefreshOnError = () => { switch (calledFunction.value) { case 'predictMore': return predictMore() case 'predictRefresh': return predictRefresh() case 'predictFromPrompt': return predictFromPrompt() default: } } onBeforeMount(init) watch( () => props.type, (newType) => { form.type = newType }, ) function init() { if (props.type === 'AI') { toggleAiMode() } else { form.title = `${capitalize(typeAlias.value)}` if (selectedViewId.value) { form.copy_from_id = selectedViewId?.value const selectedViewName = views.value.find((v) => v.id === selectedViewId.value)?.title || form.title form.title = generateUniqueTitle(`${selectedViewName} copy`, views.value, 'title', '_', true) } else { const repeatCount = views.value.filter((v) => v.title.startsWith(form.title)).length if (repeatCount) { form.title = `${form.title}-${repeatCount}` } } nextTick(() => { const el = inputEl.value?.$el as HTMLInputElement if (el) { el.focus() el.select() } }) } } const getPluralName = (name: string) => { if (aiMode.value) { return `${name}Plural` } return name } </script> <template> <NcModal v-model:visible="vModel" class="nc-view-create-modal !top-[25vh]" :show-separator="false" size="xs" height="auto" :centered="false" nc-modal-class-name="!p-0" wrap-class-name="nc-modal-view-create-wrapper" > <div class="py-5 flex flex-col gap-5" @dblclick.stop="fullAuto"> <div class="px-5 flex w-full flex-row justify-between items-center"> <div class="flex font-bold text-base gap-x-3 items-center"> <GeneralIcon v-if="isAIViewCreateMode" icon="ncAutoAwesome" class="text-nc-content-purple-dark h-6 w-6" /> <GeneralViewIcon v-else :meta="{ type: form.type }" class="nc-view-icon !text-[24px] !leading-6 max-h-6 max-w-6" /> <template v-if="form.type === ViewTypes.GRID"> <template v-if="form.copy_from_id"> {{ $t('labels.duplicateGridView') }} </template> <template v-else> {{ $t(`labels.${getPluralName('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.${getPluralName('createGalleryView')}`) }} </template> </template> <template v-else-if="form.type === ViewTypes.FORM"> <template v-if="form.copy_from_id"> {{ $t('labels.duplicateFormView') }} </template> <template v-else> {{ $t(`labels.${getPluralName('createFormView')}`) }} </template> </template> <template v-else-if="form.type === ViewTypes.KANBAN"> <template v-if="form.copy_from_id"> {{ $t('labels.duplicateKanbanView') }} </template> <template v-else> {{ $t(`labels.${getPluralName('createKanbanView')}`) }} </template> </template> <template v-else-if="form.type === ViewTypes.CALENDAR"> <template v-if="form.copy_from_id"> {{ $t('labels.duplicateCalendarView') }} </template> <template v-else> {{ $t(`labels.${getPluralName('createCalendarView')}`) }} </template> </template> <template v-else-if="form.type === 'AI'"> {{ $t('labels.createViewUsingAi') }} </template> <template v-else> <template v-if="form.copy_from_id"> {{ $t('labels.duplicateMapView') }} </template> <template v-else> {{ $t('labels.duplicateView') }} </template> </template> </div> <!-- <a v-if="!form.copy_from_id" class="text-sm !text-gray-600 !font-default !hover:text-gray-600" :href="`https://docs.nocodb.com/views/view-types/${typeAlias}`" target="_blank" > Docs </a> --> <div v-if="!isAIViewCreateMode && isNecessaryColumnsPresent && isFeatureEnabled(FEATURE_FLAG.AI_FEATURES)" :class="{ 'cursor-wait': aiLoading, }" > <NcButton type="text" size="small" class="-my-1 !text-nc-content-purple-dark hover:text-nc-content-purple-dark" :class="{ '!pointer-events-none !cursor-not-allowed': aiLoading, '!bg-nc-bg-purple-dark hover:!bg-gray-100': aiMode, }" @click.stop="aiMode ? disableAiMode() : toggleAiMode()" > <div class="flex items-center justify-center"> <GeneralIcon icon="ncAutoAwesome" /> <span class="overflow-hidden trasition-all ease duration-200" :class="{ 'w-[0px] invisible': aiMode, 'ml-1 w-[78px]': !aiMode }" > Use NocoAI </span> </div> </NcButton> </div> </div> <a-form v-if="isNecessaryColumnsPresent" ref="formValidator" :model="form" layout="vertical" class="flex flex-col gap-y-5" :class="{ '!px-5': !aiMode, }" > <template v-if="!aiMode"> <a-form-item :rules="viewNameRules" name="title" class="relative"> <a-input ref="inputEl" v-model:value="form.title" :placeholder="$t('labels.viewName')" autofocus class="nc-view-input nc-input-sm nc-input-shadow" @keydown.enter="onSubmit" /> </a-form-item> <a-form-item v-if="form.type === ViewTypes.GALLERY && !form.copy_from_id" :label="`${$t('labels.coverImageField')}`" name="fk_cover_image_col_id" > <NcSelect v-model:value="form.fk_cover_image_col_id" :disabled="isMetaLoading" :loading="isMetaLoading" dropdown-match-select-width :not-found-content="$t('placeholder.selectGroupFieldNotFound')" :placeholder="$t('placeholder.selectCoverImageField')" class="nc-select-shadow w-full nc-gallery-cover-image-field-select" > <a-select-option v-for="option of viewSelectFieldOptions" :key="option.value" :value="option.value"> <div class="w-full flex gap-2 items-center justify-between" :title="option.label"> <div class="flex-1 flex items-center gap-1 max-w-[calc(100%_-_24px)]"> <SmartsheetHeaderIcon v-if="option.value" :column="option" class="!ml-0" /> <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only> <template #title> {{ option.label }} </template> <template #default>{{ option.label }}</template> </NcTooltip> </div> <GeneralIcon v-if="form.fk_cover_image_col_id === option.value" id="nc-selected-item-icon" icon="check" class="flex-none text-primary w-4 h-4" /> </div> </a-select-option> </NcSelect> </a-form-item> <a-form-item v-if="form.type === ViewTypes.KANBAN && !form.copy_from_id" :label="$t('general.groupingField')" :rules="groupingFieldColumnRules" name="fk_grp_col_id" > <NcSelect v-model:value="form.fk_grp_col_id" :disabled="isMetaLoading" :loading="isMetaLoading" dropdown-match-select-width :not-found-content="$t('placeholder.selectGroupFieldNotFound')" :placeholder="$t('placeholder.selectGroupField')" class="nc-select-shadow w-full nc-kanban-grouping-field-select" > <a-select-option v-for="option of viewSelectFieldOptions" :key="option.value" :value="option.value"> <div class="w-full flex gap-2 items-center justify-between" :title="option.label"> <div class="flex-1 flex items-center gap-1 max-w-[calc(100%_-_24px)]"> <SmartsheetHeaderIcon :column="option" class="!ml-0" /> <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only> <template #title> {{ option.label }} </template> <template #default>{{ option.label }}</template> </NcTooltip> </div> <GeneralIcon v-if="form.fk_grp_col_id === option.value" id="nc-selected-item-icon" icon="check" class="flex-none text-primary w-4 h-4" /> </div> </a-select-option> </NcSelect> </a-form-item> <a-form-item v-if="form.type === ViewTypes.MAP" :label="$t('general.geoDataField')" :rules="geoDataFieldColumnRules" name="fk_geo_data_col_id" > <NcSelect v-model:value="form.fk_geo_data_col_id" :disabled="isMetaLoading" :loading="isMetaLoading" :not-found-content="$t('placeholder.selectGeoFieldNotFound')" :options="viewSelectFieldOptions" :placeholder="$t('placeholder.selectGeoField')" class="nc-select-shadow w-full" /> </a-form-item> <template v-if="form.type === ViewTypes.CALENDAR && !form.copy_from_id"> <div v-for="(range, index) in form.calendar_range" :key="`range-${index}`" :class="{ '!gap-2': range.fk_to_column_id === null, }" class="flex flex-col w-full gap-6" > <div class="w-full space-y-2"> <div class="text-gray-800"> {{ $t('labels.organiseBy') }} </div> <a-select v-model:value="range.fk_from_column_id" class="nc-select-shadow w-full nc-from-select !rounded-lg" dropdown-class-name="!rounded-lg" :placeholder="$t('placeholder.notSelected')" data-testid="nc-calendar-range-from-field-select" @click.stop @change="onValueChange" > <template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template> <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="w-full flex gap-2 items-center justify-between" :title="option.label"> <div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]"> <SmartsheetHeaderIcon :column="option" /> <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only> <template #title> {{ option.label }} </template> <template #default>{{ option.label }}</template> </NcTooltip> </div> <GeneralIcon v-if="option.value === range.fk_from_column_id" id="nc-selected-item-icon" icon="check" class="flex-none text-primary w-4 h-4" /> </div> </a-select-option> </a-select> </div> <div v-if="isEeUI" class="w-full space-y-2"> <NcTooltip v-if="range.fk_to_column_id === null" placement="left" :disabled="!isDisabled"> <NcButton size="small" type="text" :disabled="isDisabled" @click="range.fk_to_column_id = undefined"> <div class="flex items-center gap-1"> <component :is="iconMap.plus" class="h-4 w-4" /> {{ $t('activity.endDate') }} </div> </NcButton> <template #title> Coming Soon!! Currently, range support is only available for Date field. </template> </NcTooltip> <template v-else-if="isEeUI"> <span class="text-gray-700"> {{ $t('activity.withEndDate') }} </span> <div class="flex"> <a-select v-model:value="range.fk_to_column_id" class="nc-select-shadow w-full flex-1" allow-clear :disabled="isMetaLoading || isDisabled" :loading="isMetaLoading" :placeholder="$t('placeholder.notSelected')" data-testid="nc-calendar-range-to-field-select" dropdown-class-name="!rounded-lg" @click.stop > <template #suffixIcon><GeneralIcon icon="arrowDown" class="text-gray-700" /></template> <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 && f.value !== range.fk_from_column_id })" :key="id" :value="option.value" > <div class="w-full flex gap-2 items-center justify-between" :title="option.label"> <div class="flex items-center gap-1 max-w-[calc(100%_-_20px)]"> <SmartsheetHeaderIcon :column="option" /> <NcTooltip class="flex-1 max-w-[calc(100%_-_20px)] truncate" show-on-truncate-only> <template #title> {{ option.label }} </template> <template #default>{{ option.label }}</template> </NcTooltip> </div> <GeneralIcon v-if="option.value === range.fk_from_column_id" id="nc-selected-item-icon" icon="check" class="flex-none text-primary w-4 h-4" /> </div> </a-select-option> </a-select> </div> <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> </template> </div> </div> <!-- <NcButton class="mt-2" size="small" type="secondary" @click="addCalendarRange"> <component :is="iconMap.plus" /> Add another date field </NcButton> --> <div v-if="isCalendarReadonly(form.calendar_range)" class="flex flex-row p-4 border-gray-200 border-1 gap-x-4 rounded-lg w-full" > <div class="text-gray-500 flex gap-4"> <GeneralIcon class="min-w-6 h-6 text-orange-500" icon="info" /> <div class="flex flex-col gap-1"> <h2 class="font-semibold text-sm mb-0 text-gray-800">Calendar is readonly</h2> <span class="text-gray-500 font-default text-sm"> {{ $t('msg.info.calendarReadOnly') }}</span> </div> </div> </div> </template> </template> <template v-else> <!-- Ai view wizard --> <div v-if="!aiIntegrationAvailable" class="flex items-center gap-3 px-5 pt-2.5 pb-4.5"> <GeneralIcon icon="alertTriangleSolid" class="!text-nc-content-orange-medium w-4 h-4" /> <div class="text-sm text-nc-content-gray-subtle flex-1">{{ $t('title.noAiIntegrationAvailable') }}</div> </div> <AiWizardTabs v-else v-model:active-tab="activeAiTab"> <template #AutoSuggestedContent> <div class="px-5 pt-5 pb-2"> <div v-if="aiError" class="w-full flex items-center gap-3"> <GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-4 h-4" /> <NcTooltip class="truncate flex-1 text-sm text-nc-content-gray-subtle" show-on-truncate-only> <template #title> {{ aiError }} </template> {{ aiError }} </NcTooltip> <NcButton size="small" type="text" class="!text-nc-content-brand" @click.stop="handleRefreshOnError"> {{ $t('general.refresh') }} </NcButton> </div> <div v-else-if="aiModeStep === 'init'"> <div class="text-nc-content-purple-light text-sm h-7 flex items-center gap-2"> <GeneralLoader size="regular" class="!text-nc-content-purple-dark" /> <div class="nc-animate-dots">Auto suggesting views for {{ meta?.title }}</div> </div> </div> <div v-else-if="aiModeStep === 'pick'" class="flex gap-3 items-start"> <div class="flex-1 flex gap-2 flex-wrap"> <template v-if="activeTabPredictedViews.length"> <template v-for="v of activeTabPredictedViews" :key="v.title"> <NcTooltip :disabled="!(activeTabSelectedViews.length >= maxSelectionCount || !!v?.description)"> <template #title> <div v-if="activeTabSelectedViews.length >= maxSelectionCount" class="w-[150px]"> You can only select {{ maxSelectionCount }} views to create at a time. </div> <div v-else>{{ v?.description }}</div> </template> <a-tag class="nc-ai-suggested-tag" :class="{ 'nc-disabled': isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount), 'nc-selected': v.selected, }" :disabled="activeTabSelectedViews.length >= maxSelectionCount" @click="onToggleTag(v)" > <div class="flex flex-row items-center gap-2 py-[3px] text-small leading-[18px]"> <NcCheckbox :checked="v.selected" theme="ai" class="!-mr-0.5" :disabled="isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount)" /> <GeneralViewIcon :meta="{ type: stringToViewTypeMap[v.type] }" :class="{ 'opacity-60': isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount), }" /> <div>{{ v.title }}</div> </div> </a-tag> </NcTooltip> </template> </template> <div v-else class="text-nc-content-gray-subtle2">{{ $t('labels.noData') }}</div> </div> <div class="flex items-center gap-1"> <NcTooltip v-if=" activeTabPredictHistory.length < activeTabSelectedViews.length ? activeTabPredictHistory.length + activeTabSelectedViews.length < 10 : activeTabPredictHistory.length < 10 " title="Suggest more" placement="top" > <NcButton size="xs" class="!px-1" type="text" theme="ai" :disabled="isAiSaving" :loading="aiLoading && calledFunction === 'predictMore'" icon-only @click="predictMore" > <template #icon> <GeneralIcon icon="ncPlusAi" class="!text-current" /> </template> </NcButton> </NcTooltip> <NcTooltip title="Clear all and Re-suggest" placement="top"> <NcButton size="xs" class="!px-1" type="text" theme="ai" :disabled="isAiSaving" :loading="aiLoading && calledFunction === 'predictRefresh'" @click="predictRefresh" > <template #loadingIcon> <!-- eslint-disable vue/no-lone-template --> <template></template> </template> <GeneralIcon icon="refresh" class="!text-current" :class="{ 'animate-infinite animate-spin': aiLoading && calledFunction === 'predictRefresh', }" /> </NcButton> </NcTooltip> </div> </div> </div> </template> <template #PromptContent> <div class="px-5 pt-5 pb-2 flex flex-col gap-5"> <div class="relative"> <a-textarea ref="aiPromptInputRef" v-model:value="prompt" :disabled="isAiSaving" placeholder="Enter your prompt to get view suggestions.." class="nc-ai-input nc-input-shadow !px-3 !pt-2 !pb-3 !text-sm !min-h-[120px] !rounded-lg" @keydown.enter.stop > </a-textarea> <NcButton size="xs" type="primary" theme="ai" class="!px-1 !absolute bottom-2 right-2" :disabled=" !prompt.trim() || isPredictFromPromptLoading || (!!prompt.trim() && prompt.trim() === oldPrompt.trim()) || isAiSaving " :loading="isPredictFromPromptLoading" icon-only @click="predictFromPrompt" > <template #loadingIcon> <GeneralLoader class="!text-purple-700" size="medium" /> </template> <template #icon> <GeneralIcon icon="send" class="flex-none h-4 w-4" /> </template> </NcButton> </div> <div v-if="aiError" class="w-full flex items-center gap-3"> <GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-4 h-4" /> <NcTooltip class="truncate flex-1 text-sm text-nc-content-gray-subtle" show-on-truncate-only> <template #title> {{ aiError }} </template> {{ aiError }} </NcTooltip> <NcButton size="small" type="text" class="!text-nc-content-brand" @click.stop="handleRefreshOnError"> {{ $t('general.refresh') }} </NcButton> </div> <div v-else-if="isPromtAlreadyGenerated" class="flex flex-col gap-3"> <div class="text-nc-content-purple-dark font-semibold text-xs">Generated Views(s)</div> <div class="flex gap-2 flex-wrap"> <template v-if="activeTabPredictedViews.length"> <template v-for="v of activeTabPredictedViews" :key="v.title"> <NcTooltip :disabled="!(activeTabSelectedViews.length >= maxSelectionCount || !!v?.description)"> <template #title> <div v-if="activeTabSelectedViews.length >= maxSelectionCount" class="w-[150px]"> You can only select {{ maxSelectionCount }} views to create at a time. </div> <div v-else>{{ v?.description }}</div> </template> <a-tag class="nc-ai-suggested-tag" :class="{ 'nc-disabled': isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount), 'nc-selected': v.selected, }" :disabled="activeTabSelectedViews.length >= maxSelectionCount" @click="onToggleTag(v)" > <div class="flex flex-row items-center gap-2 py-[3px] text-small leading-[18px]"> <NcCheckbox :checked="v.selected" theme="ai" class="!-mr-0.5" :disabled="isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount)" /> <GeneralViewIcon :meta="{ type: stringToViewTypeMap[v.type] }" :class="{ 'opacity-60': isAiSaving || (!v.selected && activeTabSelectedViews.length >= maxSelectionCount), }" /> <div>{{ v.title }}</div> </div> </a-tag> </NcTooltip> </template> </template> <div v-else class="text-nc-content-gray-subtle2">{{ $t('labels.noData') }}</div> </div> </div> </div> </template> </AiWizardTabs> </template> </a-form> <div v-else-if="!isNecessaryColumnsPresent" class="px-5"> <div class="flex flex-row p-4 border-gray-200 border-1 gap-x-4 rounded-lg w-full"> <div class="text-gray-500 flex gap-4"> <GeneralIcon class="min-w-6 h-6 text-orange-500" icon="alertTriangle" /> <div class="flex flex-col gap-1"> <h2 class="font-semibold text-sm mb-0 text-gray-800">Suitable fields not present</h2> <span class="text-gray-500 font-default text-sm"> {{ errorMessages[form.type] }}</span> </div> </div> </div> </div> <a-form-item v-if="enableDescription && !aiMode" class="!px-5"> <div class="flex gap-3 text-gray-800 h-7 mt-4 mb-1 items-center justify-between"> <span class="text-[13px]"> {{ $t('labels.description') }} </span> <NcButton type="text" class="!h-6 !w-5" size="xsmall" @click="removeDescription"> <GeneralIcon icon="delete" class="text-gray-700 w-3.5 h-3.5" /> </NcButton> </div> <a-textarea ref="descriptionInputEl" v-model:value="form.description" class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]" hide-details data-testid="create-table-title-input" :placeholder="$t('msg.info.enterViewDescription')" /> </a-form-item> <div class="flex flex-row w-full justify-between gap-x-2 px-5" :class="{ '-mt-2': aiMode, }" > <NcButton v-if="!enableDescription && !aiMode" size="small" type="text" @click.stop="toggleDescription"> <div class="flex !text-gray-700 items-center gap-2"> <GeneralIcon icon="plus" class="h-4 w-4" /> <span class="first-letter:capitalize"> {{ $t('labels.addDescription') }} </span> </div> </NcButton> <div v-else></div> <div class="flex gap-2 items-center"> <NcButton type="secondary" size="small" :disabled="isAiSaving" @click="vModel = false"> {{ $t('general.cancel') }} </NcButton> <NcButton v-if="!aiMode" v-e="[form.copy_from_id ? 'a:view:duplicate' : 'a:view:create']" :disabled="!isNecessaryColumnsPresent || isViewCreating" :loading="isViewCreating" type="primary" size="small" @click="onSubmit" > {{ $t('labels.createView') }} <template #loading> {{ $t('labels.creatingView') }}</template> </NcButton> <NcButton v-else-if="aiIntegrationAvailable" v-e="[form.copy_from_id ? 'a:view:duplicate' : 'a:view:create']" type="primary" size="small" theme="ai" :disabled="activeTabSelectedViews.length === 0 || isAiSaving" :loading="isAiSaving" @click="onSubmit" > <div class="flex items-center gap-2 h-5"> {{ activeTabSelectedViews.length ? activeTabSelectedViews.length > 1 ? $t('labels.createViews_plural', { count: activeTabSelectedViews.length, }) : $t('labels.createViews', { count: activeTabSelectedViews.length, }) : $t('labels.createView') }} </div> <template #loading> {{ $t('labels.creatingView') }} </template> </NcButton> <NcButton v-else type="primary" size="small" @click="handleNavigateToIntegrations"> Add AI integration </NcButton> </div> </div> </div> </NcModal> </template> <style lang="scss" scoped> .nc-input-text-area { padding-block: 8px !important; } .ant-form-item-required { @apply !text-gray-800 font-medium; &:before { @apply !content-['']; } } .ant-form-item { @apply !mb-0; } .nc-input-sm { @apply !mb-0; } .nc-view-create-modal { :deep(.nc-modal) { } } :deep(.ant-form-item-label > label) { @apply !text-sm text-gray-800 flex; &.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before { @apply content-[''] m-0; } } .nc-nocoai-footer { @apply px-6 py-1 flex items-center gap-2 text-nc-content-purple-dark border-t-1 border-purple-100; .nc-nocoai-settings { &:not(:disabled) { @apply hover:!bg-nc-bg-purple-light; } &.nc-ai-loading { @apply !cursor-wait; } } } .nc-view-ai-mode { .nc-view-input { &:not(:focus) { @apply !rounded-r-none !border-r-0; & ~ .nc-view-ai-toggle-btn { button { @apply !pl-[7px] z-11 !border-l-1; } } } } } </style> <style lang="scss"> .nc-modal-wrapper.nc-modal-view-create-wrapper { .ant-modal-content { @apply !rounded-5; } } :deep(.ant-select) { .ant-select-selector { @apply !rounded-lg; } } </style>