diff --git a/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx b/ui/app/src/components/CreateFileDialog/CreateFileDialogContainer.tsx index d5a8410394..72ce5d2aaf 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 Box from '@mui/material/Box'; +import FormControl from '@mui/material/FormControl'; +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,57 @@ export function CreateFileDialogContainer(props: CreateFileContainerProps) { hasPendingChanges !== newHasPending && updateSubmittingOrHasPendingChanges({ hasPendingChanges: newHasPending }); }; + const onExtensionChange = (event: SelectChangeEvent) => { + setExtension(event.target.value); + 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 ( <> @@ -138,36 +201,14 @@ 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' ? ( + + {fileNameField} + {extensionField} + + ) : ( + fileNameField + )} diff --git a/ui/app/src/components/CreateFileDialog/utils.ts b/ui/app/src/components/CreateFileDialog/utils.ts index 4fbf069fb8..d4595e8cb2 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'] as const; + +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..d9b50bfce5 100644 --- a/ui/app/src/utils/path.ts +++ b/ui/app/src/utils/path.ts @@ -349,18 +349,23 @@ 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)}` - .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.