Browse Source

Merge pull request #9986 from nocodb/nc-fix/limit-display-value-col

Nc fix/limit display value col
pull/10015/head
Raju Udava 1 day ago committed by GitHub
parent
commit
c6fdb2e243
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 47
      packages/nc-gui/components/nc/List/index.vue
  2. 2
      packages/nc-gui/components/smartsheet/column/ButtonOptions.vue
  3. 53
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  4. 32
      packages/nc-gui/components/smartsheet/column/LongTextOptions.vue
  5. 2
      packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue
  6. 38
      packages/nc-gui/components/smartsheet/header/Menu.vue
  7. 80
      packages/nc-gui/components/smartsheet/header/UpdateDisplayValue.vue
  8. 80
      packages/nocodb-sdk/src/lib/UITypes.ts
  9. 20
      packages/nocodb-sdk/src/lib/helperFunctions.ts
  10. 2
      packages/nocodb-sdk/src/lib/index.ts

47
packages/nc-gui/components/nc/List/index.vue

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useVirtualList } from '@vueuse/core'
import type { TooltipPlacement } from 'ant-design-vue/lib/tooltip'
export type MultiSelectRawValueType = Array<string | number>
@ -8,6 +9,8 @@ export type RawValueType = string | number | MultiSelectRawValueType
export interface NcListItemType {
value?: RawValueType
label?: string
disabled?: boolean
ncItemTooltip?: string
[key: string]: any
}
@ -61,6 +64,8 @@ export interface NcListProps {
containerClassName?: string
itemClassName?: string
itemTooltipPlacement?: TooltipPlacement
}
interface Emits {
@ -82,6 +87,7 @@ const props = withDefaults(defineProps<NcListProps>(), {
minItemsForSearch: 4,
containerClassName: '',
itemClassName: '',
itemTooltipPlacement: 'right',
})
const emits = defineEmits<Emits>()
@ -190,7 +196,7 @@ const handleResetHoverEffect = (clearActiveOption = false, newActiveIndex?: numb
* It updates the model value, emits a change event, and optionally closes the dropdown.
*/
const handleSelectOption = (option: NcListItemType, index?: number) => {
if (!option?.[optionValueKey]) return
if (!option?.[optionValueKey] || option?.disabled) return
if (index !== undefined) {
activeOptionIndex.value = index
@ -228,6 +234,9 @@ const handleAutoScrollOption = (useDelay = false) => {
}, 150)
}
// Todo: skip arrowUp/.arrowDown on disabled options
// const getNextEnabledOptionIndex = (currentIndex: number, increment = true) => {}
const onArrowDown = () => {
keyDown.value = true
handleResetHoverEffect()
@ -364,37 +373,51 @@ watch(
:class="containerClassName"
>
<div v-bind="wrapperProps">
<div
<NcTooltip
v-for="{ data: option, index: idx } in virtualList"
:key="idx"
class="flex items-center gap-2 w-full py-2 px-2 hover:bg-gray-100 cursor-pointer rounded-md"
class="flex items-center gap-2 w-full py-2 px-2 rounded-md"
:class="[
`nc-list-option-${idx}`,
{
'nc-list-option-selected': compareVModel(option[optionValueKey]),
'bg-gray-100 ': showHoverEffectOnSelectedOption && compareVModel(option[optionValueKey]),
'bg-gray-100 nc-list-option-active': activeOptionIndex === idx,
'bg-gray-100 ': !option?.disabled && showHoverEffectOnSelectedOption && compareVModel(option[optionValueKey]),
'bg-gray-100 nc-list-option-active': !option?.disabled && activeOptionIndex === idx,
'opacity-60 cursor-not-allowed': option?.disabled,
'hover:bg-gray-100 cursor-pointer': !option?.disabled,
},
`${itemClassName}`,
]"
:placement="itemTooltipPlacement"
:disabled="!option?.ncItemTooltip"
@mouseover="handleResetHoverEffect(true, idx)"
@click="handleSelectOption(option, idx)"
>
<template #title>{{ option.ncItemTooltip }} </template>
<slot name="listItem" :option="option" :is-selected="() => compareVModel(option[optionValueKey])" :index="idx">
<slot name="listItemExtraLeft" :option="option" :is-selected="() => compareVModel(option[optionValueKey])">
</slot>
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title>
{{ option[optionLabelKey] }}
</template>
{{ option[optionLabelKey] }}
</NcTooltip>
<GeneralIcon
v-if="showSelectedOption && compareVModel(option[optionValueKey])"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
<slot name="listItemExtraRight" :option="option" :is-selected="() => compareVModel(option[optionValueKey])">
</slot>
<slot name="listItemSelectedIcon" :option="option" :is-selected="() => compareVModel(option[optionValueKey])">
<GeneralIcon
v-if="showSelectedOption && compareVModel(option[optionValueKey])"
id="nc-selected-item-icon"
icon="check"
class="flex-none text-primary w-4 h-4"
/>
</slot>
</slot>
</div>
</NcTooltip>
</div>
</div>
</div>

2
packages/nc-gui/components/smartsheet/column/ButtonOptions.vue

@ -365,7 +365,7 @@ const selectIcon = (icon: string) => {
isButtonIconDropdownOpen.value = false
}
const handleUpdateActionType = (type: ButtonActionsType) => {
const handleUpdateActionType = () => {
vModel.value.formula_raw = ''
}

53
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { type ColumnReqType, type ColumnType, isAIPromptCol } from 'nocodb-sdk'
import { type ColumnReqType, type ColumnType, isAIPromptCol, isSupportedDisplayValueColumn } from 'nocodb-sdk'
import {
ButtonActionsType,
UITypes,
@ -10,7 +10,7 @@ import {
isVirtualCol,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk'
import { AiWizardTabsType, type PredictedFieldType } from '#imports'
import { AiWizardTabsType, type PredictedFieldType, type UiTypesType } from '#imports'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'
@ -195,7 +195,7 @@ const predictedFieldType = ref<UITypes | null>(null)
// const lastPredictedAt = ref<number>(0)
const uiTypesOptions = computed<typeof uiTypes>(() => {
const uiTypesOptions = computed<(UiTypesType & { disabled?: boolean; tooltip?: string })[]>(() => {
const types = [
...uiTypes.filter(uiFilters),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
@ -236,7 +236,25 @@ const uiTypesOptions = computed<typeof uiTypes>(() => {
}
}
return types
if (!isEdit.value) {
return types
} else {
return types.map((type) => {
if (!isEdit.value) return type
const isColumnTypeDisabled =
!!column.value?.pv && column.value?.uidt !== type.name && !isSupportedDisplayValueColumn({ uidt: type.name as UITypes })
return {
...type,
disabled: isColumnTypeDisabled,
tooltip:
isColumnTypeDisabled && UITypesName[type.name]
? `${UITypesName[type.name]} field cannot be used as display value field`
: '',
}
})
}
})
const editOrAddRef = ref<HTMLDivElement>()
@ -504,10 +522,7 @@ const submitBtnLabel = computed(() => {
})
const filterOption = (input: string, option: { value: UITypes }) => {
return (
option.value.toLowerCase().includes(input.toLowerCase()) ||
(UITypesName[option.value] && UITypesName[option.value].toLowerCase().includes(input.toLowerCase()))
)
return searchCompare([option.value, ...(UITypesName[option.value] ? [UITypesName[option.value]] : [])], input)
}
const triggerDescriptionEnable = () => {
@ -1077,7 +1092,7 @@ watch(activeAiTab, (newValue) => {
v-for="opt of uiTypesOptions"
:key="opt.name"
:value="opt.name"
:disabled="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name)"
:disabled="(isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name)) || opt.disabled"
v-bind="validateInfos.uidt"
:class="{
'ant-select-item-option-active-selected': showHoverEffectOnSelectedType && formState.uidt === opt.name,
@ -1085,7 +1100,15 @@ watch(activeAiTab, (newValue) => {
}"
@mouseover="handleResetHoverEffect"
>
<div class="w-full flex gap-2 items-center justify-between" :data-testid="opt.name" :data-title="formState?.type">
<NcTooltip
class="w-full flex gap-2 items-center justify-between"
placement="right"
:disabled="!opt?.tooltip"
:attrs="{
'data-testid': opt.name,
}"
>
<template #title> {{ opt?.tooltip }} </template>
<div class="flex-1 flex gap-2 items-center max-w-[calc(100%_-_24px)]">
<component
:is="
@ -1095,8 +1118,7 @@ watch(activeAiTab, (newValue) => {
? iconMap.cellAi
: opt.icon
"
class="nc-field-type-icon w-4 h-4"
:class="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name) ? 'text-gray-300' : 'text-gray-700'"
class="nc-field-type-icon w-4 h-4 !opacity-90 text-current"
/>
<div class="flex-1">
{{ UITypesName[opt.name] }}
@ -1118,7 +1140,7 @@ watch(activeAiTab, (newValue) => {
'text-nc-content-purple-medium': isAiMode,
}"
/>
</div>
</NcTooltip>
</a-select-option>
</a-select>
</NcTooltip>
@ -1410,11 +1432,6 @@ watch(activeAiTab, (newValue) => {
}
}
:deep(.ant-select-disabled.nc-column-type-input) {
.nc-field-type-icon {
@apply text-current;
}
}
:deep(.ant-select.nc-column-type-input) {
.nc-new-field-badge {
@apply hidden;

32
packages/nc-gui/components/smartsheet/column/LongTextOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { isAIPromptCol, UITypes } from 'nocodb-sdk'
import { UITypes, UITypesName, isAIPromptCol } from 'nocodb-sdk'
const props = defineProps<{
modelValue: any
@ -59,6 +59,12 @@ const isEnabledGenerateText = computed({
},
})
const isPvColumn = computed(() => {
if (!isEdit.value) return false
return !!column.value?.pv
})
const loadViewData = async () => {
if (!formattedData.value.length) {
await loadData(undefined, false)
@ -180,10 +186,16 @@ watch(isPreviewEnabled, handleDisableSubmitBtn, {
<template>
<div class="flex flex-col gap-4">
<a-form-item>
<NcTooltip :disabled="!isEnabledGenerateText">
<template #title> Rich text formatting is not supported when generate text using AI is enabled </template>
<NcTooltip :disabled="!(isEnabledGenerateText || (isPvColumn && !richMode))">
<template #title>
{{
isPvColumn && !richMode
? `${UITypesName.RichText} field cannot be used as display value field`
: 'Rich text formatting is not supported when generate text using AI is enabled'
}}
</template>
<div class="flex items-center gap-1">
<NcSwitch v-model:checked="richMode" :disabled="isEnabledGenerateText">
<NcSwitch v-model:checked="richMode" :disabled="isEnabledGenerateText || (isPvColumn && !richMode)">
<div class="text-sm text-gray-800 select-none font-semibold">
{{ $t('labels.enableRichText') }}
</div>
@ -194,12 +206,18 @@ watch(isPreviewEnabled, handleDisableSubmitBtn, {
<div v-if="isPromptEnabled" class="relative">
<a-form-item class="flex items-center">
<NcTooltip :disabled="!richMode" class="flex items-center">
<template #title> Generate text using AI is not supported when rich text formatting is enabled </template>
<NcTooltip :disabled="!(richMode || (isPvColumn && !isEnabledGenerateText))" class="flex items-center">
<template #title>
{{
isPvColumn && !isEnabledGenerateText
? `${UITypesName.AIPrompt} field cannot be used as display value field`
: 'Generate text using AI is not supported when rich text formatting is enabled'
}}</template
>
<NcSwitch
v-model:checked="isEnabledGenerateText"
:disabled="richMode"
:disabled="richMode || (isPvColumn && !isEnabledGenerateText)"
class="nc-ai-field-generate-text nc-ai-input"
@change="handleDisableSubmitBtn"
>

2
packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue

@ -14,8 +14,6 @@ const searchQuery = ref('')
const { isMetaReadOnly } = useRoles()
const { isFeatureEnabled } = useBetaFeatureToggle()
const filteredOptions = computed(
() => options.value?.filter((c) => searchCompare([c.name, UITypesName[c.name]], searchQuery.value)) ?? [],
)

38
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -1,6 +1,12 @@
<script lang="ts" setup>
import { type ColumnReqType, type ColumnType, partialUpdateAllowedTypes, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import {
type ColumnReqType,
type ColumnType,
columnTypeName,
partialUpdateAllowedTypes,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk'
import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSupportedDisplayValueColumn, isSystemColumn } from 'nocodb-sdk'
import { SmartsheetStoreEvents, isColumnInvalid } from '#imports'
const props = defineProps<{ virtual?: boolean; isOpen: boolean; isHiddenCol?: boolean }>()
@ -578,19 +584,25 @@ const onDeleteColumn = () => {
{{ isHiddenCol ? $t('general.showField') : $t('general.hideField') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="(!virtual || column?.uidt === UITypes.Formula) && !column?.pv && !isHiddenCol"
@click="setAsDisplayValue"
<NcTooltip
v-if="column && !column?.pv && !isHiddenCol && (!virtual || column.uidt === UITypes.Formula)"
:disabled="isSupportedDisplayValueColumn(column)"
>
<div class="nc-column-set-primary nc-header-menu-item item">
<GeneralLoader v-if="isLoading === 'setDisplay'" size="regular" />
<GeneralIcon v-else icon="star" class="text-gray-500 !w-4.25 !h-4.25" />
<template #title>
{{ `${columnTypeName(column)} field cannot be used as display value field` }}
</template>
<!-- todo : tooltip -->
<!-- Set as Display value -->
{{ $t('activity.setDisplay') }}
</div>
</NcMenuItem>
<NcMenuItem :disabled="!isSupportedDisplayValueColumn(column)" @click="setAsDisplayValue">
<div class="nc-column-set-primary nc-header-menu-item item">
<GeneralLoader v-if="isLoading === 'setDisplay'" size="regular" />
<GeneralIcon v-else icon="star" class="text-gray-500 !w-4.25 !h-4.25" />
<!-- todo : tooltip -->
<!-- Set as Display value -->
{{ $t('activity.setDisplay') }}
</div>
</NcMenuItem>
</NcTooltip>
<template v-if="!isExpandedForm">
<a-divider v-if="!isLinksOrLTAR(column) || column.colOptions.type !== RelationTypes.BELONGS_TO" class="!my-0" />

80
packages/nc-gui/components/smartsheet/header/UpdateDisplayValue.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { type ColumnType, isVirtualCol } from 'nocodb-sdk'
import { type ColumnType, columnTypeName, isSupportedDisplayValueColumn, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
interface Props {
column: ColumnType
@ -18,13 +18,11 @@ const { fields } = useViewColumnsOrThrow()
const meta = inject(MetaInj, ref())
const searchField = ref('')
const column = toRef(props, 'column')
const value = useVModel(props, 'value')
const selectedField = ref()
const selectedFieldId = ref()
const isLoading = ref(false)
@ -32,15 +30,28 @@ const filteredColumns = computed(() => {
const columns = meta.value?.columnsById ?? {}
return (fields.value ?? [])
.filter((f) => !isVirtualCol(columns[f.fk_column_id]))
.filter((c) => c.title.toLowerCase().includes(searchField.value.toLowerCase()))
.filter((f) => columns[f?.fk_column_id] && !isSystemColumn(columns[f.fk_column_id]))
.map((f) => {
const column = columns[f.fk_column_id] as ColumnType
return {
title: f.title,
id: f.fk_column_id,
disabled: !isSupportedDisplayValueColumn(column) && !column.pv,
ncItemTooltip:
!isSupportedDisplayValueColumn(column) && columnTypeName(column) && !column.pv
? `${columnTypeName(column)} field cannot be used as display value field`
: '',
column,
}
})
})
const changeDisplayField = async () => {
if (!selectedFieldId.value) return
isLoading.value = true
try {
await $api.dbTableColumn.primaryColumnSet(selectedField?.value?.fk_column_id as string)
await $api.dbTableColumn.primaryColumnSet(selectedFieldId.value)
await getMeta(meta?.value?.id as string, true)
@ -53,14 +64,13 @@ const changeDisplayField = async () => {
}
}
const getIcon = (c: ColumnType) =>
const cellIcon = (c: ColumnType) =>
h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), {
columnMeta: c,
})
onMounted(() => {
searchField.value = ''
selectedField.value = fields.value?.find((f) => f.fk_column_id === column.value.id)
selectedFieldId.value = fields.value?.find((f) => f.fk_column_id === column.value.id)?.fk_column_id
})
</script>
@ -79,39 +89,23 @@ onMounted(() => {
</div>
</div>
<div class="flex w-full gap-2 justify-between items-center">
<a-input v-model:value="searchField" class="w-full h-8 flex-1" size="small" :placeholder="$t('placeholder.searchFields')">
<template #prefix>
<component :is="iconMap.search" class="w-4 text-gray-500 h-4" />
</template>
</a-input>
</div>
<div class="border-1 rounded-md h-[250px] nc-scrollbar-md border-gray-200">
<div
v-for="col in filteredColumns"
:key="col.fk_column_id"
:class="{
'bg-gray-100': selectedField === col,
}"
:data-testid="`nc-display-field-update-menu-${col.title}`"
class="px-3 py-1 flex flex-row items-center rounded-md hover:bg-gray-100"
@click.stop="selectedField = col"
<div class="border-1 rounded-lg border-nc-border-gray-medium h-[250px]">
<NcList
v-model:value="selectedFieldId"
v-model:open="value"
:list="filteredColumns"
search-input-placeholder="Search"
option-label-key="title"
option-value-key="id"
:close-on-select="false"
class="!w-auto"
show-search-always
container-class-name="!max-h-[200px]"
>
<div class="flex flex-row items-center w-full cursor-pointer truncate ml-1 py-[5px] pr-2">
<component :is="getIcon(meta.columnsById[col.fk_column_id])" class="!w-3.5 !h-3.5 !text-gray-500" />
<NcTooltip class="flex-1 pl-1 pr-2 truncate" show-on-truncate-only>
<template #title>
{{ col.title }}
</template>
<template #default>{{ col.title }}</template>
</NcTooltip>
</div>
<div class="flex-1" />
<component :is="iconMap.check" v-if="selectedField === col" class="!w-4 !h-4 !text-brand-500" />
</div>
<template #listItemExtraLeft="{ option }">
<component :is="cellIcon(option.column)" class="!mx-0 opacity-70" />
</template>
</NcList>
</div>
<div class="flex w-full gap-2 justify-end">
@ -120,7 +114,7 @@ onMounted(() => {
</NcButton>
<NcButton
:disabled="!selectedField || selectedField.fk_column_id === column.id"
:disabled="!selectedFieldId || selectedFieldId === column.id"
:loading="isLoading"
size="small"
@click="changeDisplayField"

80
packages/nocodb-sdk/src/lib/UITypes.ts

@ -1,7 +1,7 @@
import { ColumnReqType, ColumnType, TableType } from './Api';
import { FormulaDataTypes } from './formulaHelpers';
import { LongTextAiMetaProp, RelationTypes } from '~/lib/globals';
import { parseHelper } from './helperFunctions';
import { parseHelper, ncParseProp } from './helperFunctions';
enum UITypes {
ID = 'ID',
@ -94,6 +94,37 @@ export const UITypesName = {
AIPrompt: 'AI Prompt',
};
export const columnTypeName = (column?: ColumnType) => {
if (!column) return '';
switch (column.uidt) {
case UITypes.LongText: {
if (ncParseProp(column.meta)?.richMode) {
return UITypesName.RichText;
}
if (ncParseProp(column.meta)[LongTextAiMetaProp]) {
return UITypesName.AIPrompt;
}
return UITypesName[column.uidt];
}
case UITypes.Button: {
if (
column.uidt === UITypes.Button &&
(column?.colOptions as any)?.type === 'ai'
) {
return UITypesName.AIButton;
}
return UITypesName[column.uidt];
}
default: {
return column.uidt ? UITypesName[column.uidt] : '';
}
}
};
export const FieldNameFromUITypes: Record<UITypes, string> = {
[UITypes.ID]: 'ID',
[UITypes.LinkToAnotherRecord]: '{TableName}',
@ -188,12 +219,11 @@ export function isVirtualCol(
].includes(<UITypes>(typeof col === 'object' ? col?.uidt : col));
}
export function isAIPromptCol(
col:
| ColumnReqType
| ColumnType
) {
return col.uidt === UITypes.LongText && parseHelper((col as any)?.meta)?.[LongTextAiMetaProp];
export function isAIPromptCol(col: ColumnReqType | ColumnType) {
return (
col.uidt === UITypes.LongText &&
parseHelper((col as any)?.meta)?.[LongTextAiMetaProp]
);
}
export function isCreatedOrLastModifiedTimeCol(
@ -331,3 +361,39 @@ export const getUITypesForFormulaDataType = (
return [];
}
};
export const isSupportedDisplayValueColumn = (column: Partial<ColumnType>) => {
if (!column?.uidt) return false;
switch (column.uidt) {
case UITypes.SingleLineText:
case UITypes.Date:
case UITypes.DateTime:
case UITypes.Time:
case UITypes.Year:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
case UITypes.Number:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Decimal:
case UITypes.Formula: {
return true;
}
case UITypes.LongText: {
if (
ncParseProp(column.meta)?.richMode ||
ncParseProp(column.meta)[LongTextAiMetaProp]
) {
return false;
}
return true;
}
default: {
return false;
}
}
};

20
packages/nocodb-sdk/src/lib/helperFunctions.ts

@ -228,6 +228,24 @@ export const integrationCategoryNeedDefault = (category: IntegrationsType) => {
return [IntegrationsType.Ai].includes(category);
};
export function ncParseProp(v: any): any {
if (!v) return {};
try {
return typeof v === 'string' ? JSON.parse(v) ?? {} : v;
} catch {
return {};
}
}
export function ncStringifyProp(v: any): string {
if (!v) return '{}';
try {
return typeof v === 'string' ? v : JSON.stringify(v) ?? '{}';
} catch {
return '{}';
}
}
export function parseHelper(v: any): any {
try {
return typeof v === 'string' ? JSON.parse(v) : v;
@ -238,7 +256,7 @@ export function parseHelper(v: any): any {
export function stringifyHelper(v: any): string {
try {
return JSON.stringify(v);
return typeof v === 'string' ? v : JSON.stringify(v);
} catch {
return v;
}

2
packages/nocodb-sdk/src/lib/index.ts

@ -24,6 +24,8 @@ export {
getUITypesForFormulaDataType,
readonlyMetaAllowedTypes,
partialUpdateAllowedTypes,
isSupportedDisplayValueColumn,
columnTypeName,
} from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

Loading…
Cancel
Save