|
|
|
<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 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>
|
|
|
|
<NcSelect
|
|
|
|
v-model:value="range.fk_from_column_id"
|
|
|
|
:disabled="isMetaLoading"
|
|
|
|
:loading="isMetaLoading"
|
|
|
|
class="nc-select-shadow w-full nc-from-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
|
|
|
|
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"
|
|
|
|
class="w-40"
|
|
|
|
:value="option.value"
|
|
|
|
>
|
|
|
|
<div class="flex w-full gap-2 justify-between items-center">
|
|
|
|
<div class="flex gap-2 items-center">
|
|
|
|
<SmartsheetHeaderIcon :column="option" class="!ml-0" />
|
|
|
|
<NcTooltip class="truncate flex-1 max-w-18" placement="top" show-on-truncate-only>
|
|
|
|
<template #title>{{ option.label }}</template>
|
|
|
|
{{ option.label }}
|
|
|
|
</NcTooltip>
|
|
|
|
</div>
|
|
|
|
<div class="flex-1" />
|
|
|
|
<component
|
|
|
|
:is="iconMap.check"
|
|
|
|
v-if="option.value === range.fk_from_column_id"
|
|
|
|
id="nc-selected-item-icon"
|
|
|
|
class="text-primary min-w-4 h-4"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</a-select-option>
|
|
|
|
</NcSelect>
|
|
|
|
</div>
|
|
|
|
<div class="w-full space-y-2">
|
|
|
|
<NcButton
|
|
|
|
v-if="range.fk_to_column_id === null && isRangeEnabled"
|
|
|
|
size="small"
|
|
|
|
class="!border-none w-28"
|
|
|
|
type="secondary"
|
|
|
|
:disabled="!isEeUI"
|
|
|
|
@click="range.fk_to_column_id = undefined"
|
|
|
|
>
|
|
|
|
<component :is="iconMap.plus" class="h-4 w-4" />
|
|
|
|
{{ $t('activity.endDate') }}
|
|
|
|
</NcButton>
|
|
|
|
|
|
|
|
<template v-else-if="isEeUI && isRangeEnabled">
|
|
|
|
<span class="text-gray-700">
|
|
|
|
{{ $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="nc-to-select flex-1"
|
|
|
|
>
|
|
|
|
<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="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>
|
|
|
|
<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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
:not(.nc-to-select) {
|
|
|
|
:deep(.ant-select) {
|
|
|
|
.ant-select-selector {
|
|
|
|
@apply !rounded-lg;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.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(.nc-to-select) {
|
|
|
|
.ant-select-selector {
|
|
|
|
@apply !rounded-r-none;
|
|
|
|
border-radius-right: 0rem !important;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|