From c241646ddc1be604ff6bf64b32a593766465c343 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Mon, 15 Jun 2026 10:33:02 -0600 Subject: [PATCH 1/4] [7359] Add support for alternate Freemarker suffixes besides .ftl (.ftlh .ftlx) --- .../CreateFileDialogContainer.tsx | 47 +++++++++++++++++-- .../src/components/CreateFileDialog/utils.ts | 6 +++ ui/app/src/utils/path.ts | 12 +++-- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx index d5a8410394..d21dc1962e 100644 --- a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx +++ b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx @@ -22,11 +22,15 @@ import { checkPathExistence, createFile } from '../../services/content'; import { validateActionPolicy } from '../../services/sites'; import DialogBody from '../DialogBody/DialogBody'; import TextField from '@mui/material/TextField'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; import DialogFooter from '../DialogFooter/DialogFooter'; import SecondaryButton from '../SecondaryButton'; import PrimaryButton from '../PrimaryButton'; import ConfirmDialog from '../ConfirmDialog'; -import { CreateFileContainerProps } from './utils'; +import { CreateFileContainerProps, DEFAULT_TEMPLATE_EXTENSION, TEMPLATE_EXTENSIONS, TemplateExtension } from './utils'; import { translations } from './translations'; import useEnhancedDialogContext from '../EnhancedDialog/useEnhancedDialogContext'; import useItemsByPath from '../../hooks/useItemsByPath'; @@ -42,12 +46,15 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { const { onClose, onCreated, type, path, allowBraces } = props; const { isSubmitting, hasPendingChanges } = useEnhancedDialogContext(); const [name, setName] = useState(''); + const [extension, setExtension] = useState(DEFAULT_TEMPLATE_EXTENSION); const [confirm, setConfirm] = useState(null); const dispatch = useDispatch(); const site = useActiveSiteId(); const { formatMessage } = useIntl(); const itemLookup = useItemsByPath(); - const computedFilePath = `${path}/${getFileNameWithExtensionForItemType(type, name)}`; + const getFileName = (fileName: string) => + getFileNameWithExtensionForItemType(type, fileName, type === 'template' ? extension : undefined); + const computedFilePath = `${path}/${getFileName(name)}`; // When calling the validation API, we need to check if the item with the suggested name exists. This is an extra validation for the // fileExists const. const [itemExists, setItemExists] = useState(false); @@ -64,7 +71,12 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { createFile(site, path, fileName).subscribe({ next() { updateSubmittingOrHasPendingChanges({ hasPendingChanges: false, isSubmitting: false }); - onCreated?.({ path, fileName, mode: pickExtensionForItemType(type), openOnSuccess: true }); + onCreated?.({ + path, + fileName, + mode: pickExtensionForItemType(type, fileName, type === 'template' ? extension : undefined), + openOnSuccess: true + }); }, error: onError }); @@ -79,7 +91,7 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { }).subscribe({ next: ({ allowed, modifiedValue, message }) => { if (allowed) { - const fileName = getFileNameWithExtensionForItemType(type, name); + const fileName = getFileName(name); const pathToCheckExists = modifiedValue ?? `${path}/${fileName}`; setItemExists(false); checkPathExistence(site, pathToCheckExists).subscribe({ @@ -111,7 +123,7 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { }; const onConfirm = () => { - const fileName = getFileNameWithExtensionForItemType(type, name); + const fileName = getFileName(name); onCreateFile(site, path, fileName); }; @@ -127,6 +139,11 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { hasPendingChanges !== newHasPending && updateSubmittingOrHasPendingChanges({ hasPendingChanges: newHasPending }); }; + const onExtensionChange = (event: SelectChangeEvent) => { + setExtension(event.target.value as TemplateExtension); + setItemExists(false); + }; + return ( <> @@ -168,6 +185,26 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { }} onChange={(event) => onInputChanges(applyAssetNameRules(event.target.value, { allowBraces }))} /> + {type === 'template' && ( + + + + + + + )} diff --git a/ui/app/src/components/CreateFileDialog/utils.ts b/ui/app/src/components/CreateFileDialog/utils.ts index 4fbf069fb8..70cc04856e 100644 --- a/ui/app/src/components/CreateFileDialog/utils.ts +++ b/ui/app/src/components/CreateFileDialog/utils.ts @@ -35,3 +35,9 @@ export interface CreateFileStateProps extends CreateFileBaseProps, EnhancedDialo } export interface CreateFileContainerProps extends CreateFileBaseProps, Pick {} + +export const TEMPLATE_EXTENSIONS = ['ftl', 'ftlh', 'ftlx']; + +export type TemplateExtension = (typeof TEMPLATE_EXTENSIONS)[number]; + +export const DEFAULT_TEMPLATE_EXTENSION: TemplateExtension = 'ftl'; diff --git a/ui/app/src/utils/path.ts b/ui/app/src/utils/path.ts index e1d8b52450..f0d79271e7 100644 --- a/ui/app/src/utils/path.ts +++ b/ui/app/src/utils/path.ts @@ -349,16 +349,20 @@ export function processPathMacros(dependencies: { return processedPath; } -export const pickExtensionForItemType = (systemType: string, name?: string) => { +export const pickExtensionForItemType = (systemType: string, name?: string, extension?: string) => { if (systemType === 'asset') { return getFileExtension(name); + } else if (systemType === 'controller') { + return 'groovy'; + } else if (extension) { + return extension.replace(/^\./, ''); } else { - return systemType === 'controller' ? `groovy` : `ftl`; + return 'ftl'; } }; -export const getFileNameWithExtensionForItemType = (type: string, name: string) => - `${name}.${pickExtensionForItemType(type)}` +export const getFileNameWithExtensionForItemType = (type: string, name: string, extension?: string) => + `${name}.${pickExtensionForItemType(type, name, extension)}` .replace(/(\.groovy)(\.groovy)|(\.ftl)(\.ftl)/g, '$1$3') .replace(/\.{2,}/g, '.'); From b381d7f986e9b0f1224b18d41ba2852668a5de09 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 16 Jun 2026 07:54:10 -0600 Subject: [PATCH 2/4] [7359] allow developers to choose the template type when they create templates in Studio --- .../CreateFileDialogContainer.tsx | 104 +++++++++--------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx index d21dc1962e..922467c602 100644 --- a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx +++ b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx @@ -22,8 +22,8 @@ import { checkPathExistence, createFile } from '../../services/content'; import { validateActionPolicy } from '../../services/sites'; import DialogBody from '../DialogBody/DialogBody'; import TextField from '@mui/material/TextField'; +import Box from '@mui/material/Box'; import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; import Select, { SelectChangeEvent } from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import DialogFooter from '../DialogFooter/DialogFooter'; @@ -144,6 +144,52 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { setItemExists(false); }; + const fileNameField = ( + } + value={name} + fullWidth={type !== 'template'} + autoFocus + required + error={(!name && Boolean(isSubmitting)) || fileExists} + placeholder={formatMessage(translations.placeholder)} + helperText={ + fileExists ? ( + + ) : !name && isSubmitting ? ( + + ) : ( + + ) + } + disabled={isSubmitting} + margin={type === 'template' ? 'none' : 'normal'} + sx={type === 'template' ? { flex: 1 } : undefined} + slotProps={{ + inputLabel: { shrink: true } + }} + onChange={(event) => onInputChanges(applyAssetNameRules(event.target.value, { allowBraces }))} + /> + ); + + const extensionField = ( + + + + ); + return ( <> @@ -155,55 +201,13 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { } }} > - } - value={name} - fullWidth - autoFocus - required - error={(!name && Boolean(isSubmitting)) || fileExists} - placeholder={formatMessage(translations.placeholder)} - helperText={ - fileExists ? ( - - ) : !name && isSubmitting ? ( - - ) : ( - - ) - } - disabled={isSubmitting} - margin="normal" - slotProps={{ - inputLabel: { shrink: true } - }} - onChange={(event) => onInputChanges(applyAssetNameRules(event.target.value, { allowBraces }))} - /> - {type === 'template' && ( - - - - - - + {type === 'template' ? ( + + {fileNameField} + {extensionField} + + ) : ( + fileNameField )} From 985eba018d40b3e105fe4835c54398486c408041 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 16 Jun 2026 08:26:15 -0600 Subject: [PATCH 3/4] Update CreateFileDialog to ensure proper type handling for template extensions --- .../components/CreateFileDialog/CreateFileDialogContainer.tsx | 2 +- ui/app/src/components/CreateFileDialog/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx index 922467c602..72ce5d2aaf 100644 --- a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx +++ b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx @@ -140,7 +140,7 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { }; const onExtensionChange = (event: SelectChangeEvent) => { - setExtension(event.target.value as TemplateExtension); + setExtension(event.target.value); setItemExists(false); }; diff --git a/ui/app/src/components/CreateFileDialog/utils.ts b/ui/app/src/components/CreateFileDialog/utils.ts index 70cc04856e..d4595e8cb2 100644 --- a/ui/app/src/components/CreateFileDialog/utils.ts +++ b/ui/app/src/components/CreateFileDialog/utils.ts @@ -36,7 +36,7 @@ export interface CreateFileStateProps extends CreateFileBaseProps, EnhancedDialo export interface CreateFileContainerProps extends CreateFileBaseProps, Pick {} -export const TEMPLATE_EXTENSIONS = ['ftl', 'ftlh', 'ftlx']; +export const TEMPLATE_EXTENSIONS = ['ftl', 'ftlh', 'ftlx'] as const; export type TemplateExtension = (typeof TEMPLATE_EXTENSIONS)[number]; From 9a4d4c8fa7ba2f10105e2795a7d0ccda28a456c8 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 16 Jun 2026 08:30:32 -0600 Subject: [PATCH 4/4] Refactor getFileNameWithExtensionForItemType to normalize file names by removing existing extensions before appending the picked extension. --- ui/app/src/utils/path.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/app/src/utils/path.ts b/ui/app/src/utils/path.ts index f0d79271e7..d9b50bfce5 100644 --- a/ui/app/src/utils/path.ts +++ b/ui/app/src/utils/path.ts @@ -361,10 +361,11 @@ export const pickExtensionForItemType = (systemType: string, name?: string, exte } }; -export const getFileNameWithExtensionForItemType = (type: string, name: string, extension?: string) => - `${name}.${pickExtensionForItemType(type, name, extension)}` - .replace(/(\.groovy)(\.groovy)|(\.ftl)(\.ftl)/g, '$1$3') - .replace(/\.{2,}/g, '.'); +export const getFileNameWithExtensionForItemType = (type: string, name: string, extension?: string) => { + const pickedExtension = pickExtensionForItemType(type, name, extension); + const normalizedName = name.replace(new RegExp(`\\.${pickedExtension}$`), ''); + return `${normalizedName}.${pickedExtension}`.replace(/\.{2,}/g, '.'); +}; /** * Determines if the given path corresponds to a page path.