Skip to content

Commit 7f5908d

Browse files
authored
Merge pull request #15240 from codeconsole/7.1.x-namespace-view-defaults
Allow namespace scaffold views to default to scaffold namespace templates instead of non namespace views. Fixes #15239
2 parents f35f3ce + 76b58e5 commit 7f5908d

File tree

5 files changed

+481
-42
lines changed

5 files changed

+481
-42
lines changed

grails-doc/src/en/guide/upgrading/upgrading60x.adoc

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1162,4 +1162,79 @@ grails {
11621162
}
11631163
----
11641164

1165-
This allows you to use classes from these packages without explicit imports throughout your Groovy code. The `starImports` configuration works independently and will be combined with any imports from `importGrailsCommonAnnotations` or `importJavaTime` flags if those are also enabled.
1165+
This allows you to use classes from these packages without explicit imports throughout your Groovy code. The `starImports` configuration works independently and will be combined with any imports from `importGrailsCommonAnnotations` or `importJavaTime` flags if those are also enabled.
1166+
1167+
===== 12.30 Scaffolding Namespace View Defaults
1168+
1169+
Grails 7.1 introduces an opt-in feature for scaffolding that allows namespace-specific scaffolded templates to take priority over non-namespaced view fallbacks.
1170+
1171+
====== Background
1172+
1173+
Previously, when a namespace controller requested a view, the scaffolding plugin would only generate a scaffolded view if no view existed at all. This meant that if you had:
1174+
1175+
* A namespace controller (e.g., `namespace = 'admin'`)
1176+
* A non-namespaced view in `grails-app/views/event/index.gsp`
1177+
* A namespace-specific scaffolded template in `src/main/templates/scaffolding/admin/index.gsp`
1178+
1179+
The non-namespaced view would always be used, and the namespace-specific scaffolded template would be ignored.
1180+
1181+
====== New Behavior
1182+
1183+
With the new `enableNamespaceViewDefaults` configuration, namespace-specific scaffolded templates can now override non-namespaced view fallbacks. This provides better support for namespace-specific customization of scaffolded views.
1184+
1185+
====== Configuration
1186+
1187+
To enable this feature, add the following to your `application.yml`:
1188+
1189+
[source,yml]
1190+
.application.yml
1191+
----
1192+
grails:
1193+
scaffolding:
1194+
enableNamespaceViewDefaults: true
1195+
----
1196+
1197+
====== View Resolution Priority
1198+
1199+
When `enableNamespaceViewDefaults` is enabled, the view resolution priority for namespace controllers is:
1200+
1201+
1. **Namespace-specific view** (e.g., `grails-app/views/admin/event/index.gsp`)
1202+
- If exists → used (highest priority)
1203+
1204+
2. **Namespace-specific scaffolded template** (e.g., `src/main/templates/scaffolding/admin/index.gsp`)
1205+
- If exists and no namespace view → used (overrides fallback)
1206+
1207+
3. **Non-namespaced view fallback** (e.g., `grails-app/views/event/index.gsp`)
1208+
- Used if no namespace view or scaffolded template exists
1209+
1210+
4. **Non-namespaced scaffolded template** (e.g., `src/main/templates/scaffolding/index.gsp`)
1211+
- Used if no views exist at all
1212+
1213+
====== Example Use Case
1214+
1215+
This feature is useful when you want different scaffolded views for different namespaces:
1216+
1217+
[source,groovy]
1218+
----
1219+
// Regular event controller
1220+
@Scaffold(RestfulServiceController<Event>)
1221+
class EventController {
1222+
}
1223+
1224+
// Admin event controller with namespace
1225+
@Scaffold(RestfulServiceController<Event>)
1226+
class EventController {
1227+
static namespace = 'admin'
1228+
}
1229+
----
1230+
1231+
With `enableNamespaceViewDefaults: true`, you can provide:
1232+
1233+
* `src/main/templates/scaffolding/index.gsp` - Default scaffolded template
1234+
* `src/main/templates/scaffolding/admin/index.gsp` - Admin-specific scaffolded template
1235+
1236+
The admin controller will use the admin-specific template even if a non-namespaced view exists.
1237+
1238+
====== Backward Compatibility
1239+
1240+
This feature is **disabled by default** (`false`), ensuring complete backward compatibility. Existing applications will continue to work without any changes. Enable the feature only when you need namespace-specific scaffolded template support.

grails-scaffolding/build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ dependencies {
4040
api project(':grails-rest-transforms')
4141

4242
compileOnly 'jline:jline'
43+
44+
testImplementation 'org.spockframework:spock-core'
45+
testImplementation project(':grails-web-gsp')
46+
testImplementation project(':grails-core')
47+
testImplementation 'jakarta.servlet:jakarta.servlet-api'
48+
testImplementation 'org.springframework:spring-web'
49+
testImplementation 'net.bytebuddy:byte-buddy'
4350
}
4451

4552
apply {

grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingGrailsPlugin.groovy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Plugin that generates scaffolded controllers and views for a Grails application.
6666
bean.lazyInit = true
6767
bean.parent = 'abstractViewResolver'
6868
enableReload = reloadEnabled
69+
enableNamespaceViewDefaults = config.getProperty('grails.scaffolding.enableNamespaceViewDefaults', Boolean, false)
6970
}
7071
}
7172
}

grails-scaffolding/src/main/groovy/grails/plugin/scaffolding/ScaffoldingViewResolver.groovy

Lines changed: 124 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.springframework.core.io.UrlResource
3434
import org.springframework.web.servlet.View
3535

3636
import grails.codegen.model.ModelBuilder
37+
import grails.core.GrailsControllerClass
3738
import grails.io.IOUtils
3839
import grails.plugin.scaffolding.annotation.Scaffold
3940
import grails.util.BuildSettings
@@ -84,13 +85,22 @@ class ScaffoldingViewResolver extends GroovyPageViewResolver implements Resource
8485
this.templateOverridePluginDescriptor = templateOverridePluginDescriptor
8586
}
8687

88+
private static final Object NULL_SCAFFOLD_VALUE = new Object()
89+
8790
ResourceLoader resourceLoader
8891
protected Map<String, View> generatedViewCache = new ConcurrentHashMap<>()
92+
protected Map<Class, Object> scaffoldValueCache = new ConcurrentHashMap<>()
8993
protected boolean enableReload = false
94+
protected boolean enableNamespaceViewDefaults = false
95+
9096
void setEnableReload(boolean enableReload) {
9197
this.enableReload = enableReload
9298
}
9399

100+
void setEnableNamespaceViewDefaults(boolean enableNamespaceViewDefaults) {
101+
this.enableNamespaceViewDefaults = enableNamespaceViewDefaults
102+
}
103+
94104
protected String buildCacheKey(String viewName) {
95105
String viewCacheKey = groovyPageLocator.resolveViewFormat(viewName)
96106
String currentControllerKeyPrefix = resolveCurrentControllerKeyPrefixes(viewName.startsWith('/'))
@@ -128,53 +138,126 @@ class ScaffoldingViewResolver extends GroovyPageViewResolver implements Resource
128138
@Override
129139
protected View loadView(String viewName, Locale locale) throws Exception {
130140
def view = super.loadView(viewName, locale)
131-
if (view == null) {
132-
String cacheKey = buildCacheKey(viewName)
133-
view = enableReload ? null : generatedViewCache.get(cacheKey)
134-
if (view != null) {
141+
142+
if (view != null) {
143+
if (!enableNamespaceViewDefaults) {
135144
return view
136-
} else {
137-
def webR = GrailsWebRequest.lookup()
138-
def controllerClass = webR.controllerClass
139-
140-
def scaffoldValue = controllerClass?.getPropertyValue('scaffold')
141-
if (!scaffoldValue) {
142-
Scaffold scaffoldAnnotation = controllerClass?.clazz?.getAnnotation(Scaffold)
143-
scaffoldValue = scaffoldAnnotation?.domain()
144-
if (scaffoldValue == Void) {
145-
scaffoldValue = null
146-
}
145+
}
146+
147+
def controllerClass = GrailsWebRequest.lookup()?.controllerClass
148+
if (controllerClass?.namespace) {
149+
// Check if the view found is already a namespace-specific view
150+
def isNamespaceSpecificView = view instanceof GroovyPageView &&
151+
view.url?.contains("/${controllerClass.namespace}/")
152+
153+
if (!isNamespaceSpecificView) {
154+
// View is a fallback (non-namespaced), check for namespace-specific scaffolded template
155+
return tryGenerateScaffoldedView(viewName, controllerClass) { String shortViewName ->
156+
// Only check namespace-specific template
157+
resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}")
158+
} ?: view
147159
}
160+
}
161+
return view
162+
}
163+
164+
def controllerClass = GrailsWebRequest.lookup()?.controllerClass
165+
166+
return tryGenerateScaffoldedView(viewName, controllerClass) { String shortViewName ->
167+
Resource res = controllerClass?.namespace ? resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}") : null
168+
if (!res?.exists()) {
169+
res = resolveResource(controllerClass.clazz, shortViewName)
170+
}
171+
return res
172+
}
173+
}
174+
175+
/**
176+
* Attempts to generate a scaffolded view for the given controller
177+
* @param viewName The view name
178+
* @param controllerClass The controller class
179+
* @param resourceResolver Closure that resolves the scaffold template resource given a short view name
180+
* @return The generated scaffolded view, or null if not applicable
181+
*/
182+
private View tryGenerateScaffoldedView(String viewName, GrailsControllerClass controllerClass, Closure<Resource> resourceResolver) {
183+
def scaffoldValue = getScaffoldValue(controllerClass)
184+
if (!(scaffoldValue instanceof Class)) {
185+
return null
186+
}
187+
188+
String cacheKey = buildCacheKey(viewName)
148189

149-
if (scaffoldValue instanceof Class) {
150-
def shortViewName = viewName.substring(viewName.lastIndexOf('/') + 1)
151-
Resource res = controllerClass.namespace ? resolveResource(controllerClass.clazz, "${controllerClass.namespace}/${shortViewName}") : null
152-
if (!res?.exists()) {
153-
res = resolveResource(controllerClass.clazz, shortViewName)
154-
}
155-
if (res.exists()) {
156-
def model = model((Class) scaffoldValue)
157-
def viewGenerator = new GStringTemplateEngine()
158-
Template t = viewGenerator.createTemplate(res.URL)
159-
160-
def contents = new FastStringWriter()
161-
t.make(model.asMap()).writeTo(contents)
162-
163-
def template = templateEngine.createTemplate(new ByteArrayResource(contents.toString().getBytes(templateEngine.gspEncoding), "view:$cacheKey"), !enableReload)
164-
view = new GroovyPageView()
165-
view.setServletContext(getServletContext())
166-
view.setTemplate(template)
167-
view.setApplicationContext(getApplicationContext())
168-
view.setTemplateEngine(templateEngine)
169-
view.afterPropertiesSet()
170-
generatedViewCache.put(cacheKey, view)
171-
return view
172-
} else {
173-
return view
174-
}
190+
// Check cache first
191+
def cachedScaffoldedView = enableReload ? null : generatedViewCache.get(cacheKey)
192+
if (cachedScaffoldedView != null) {
193+
return cachedScaffoldedView
194+
}
195+
196+
def shortViewName = viewName.substring(viewName.lastIndexOf('/') + 1)
197+
Resource scaffoldResource = resourceResolver.call(shortViewName)
198+
199+
if (scaffoldResource?.exists()) {
200+
return generateScaffoldedView(scaffoldValue, scaffoldResource, cacheKey)
201+
}
202+
203+
return null
204+
}
205+
206+
private Object getScaffoldValue(GrailsControllerClass controllerClass) {
207+
if (!controllerClass) {
208+
return null
209+
}
210+
211+
// Cache the scaffold value to avoid repeated reflection
212+
Class controllerClazz = controllerClass.clazz
213+
if (scaffoldValueCache.containsKey(controllerClazz)) {
214+
Object cached = scaffoldValueCache.get(controllerClazz)
215+
return cached == NULL_SCAFFOLD_VALUE ? null : cached
216+
}
217+
218+
def scaffoldValue = controllerClass.getPropertyValue('scaffold')
219+
if (!scaffoldValue) {
220+
Scaffold scaffoldAnnotation = controllerClazz?.getAnnotation(Scaffold)
221+
if (scaffoldAnnotation) {
222+
// Check domain() attribute for view scaffolding - domain class is required for model generation.
223+
// Note: For @Scaffold(RestfulServiceController<T>), the AST transformation
224+
// (ScaffoldingControllerInjector) extracts T and sets it as domain() at compile time,
225+
// so this works for both @Scaffold(domain = User) and @Scaffold(RestfulServiceController<User>).
226+
scaffoldValue = scaffoldAnnotation.domain()
227+
if (scaffoldValue == Void) {
228+
scaffoldValue = null
175229
}
176230
}
177231
}
232+
233+
// Cache the result (even if null, to avoid repeated lookups)
234+
scaffoldValueCache.put(controllerClazz, scaffoldValue == null ? NULL_SCAFFOLD_VALUE : scaffoldValue)
235+
return scaffoldValue
236+
}
237+
238+
private View generateScaffoldedView(Class scaffoldValue, Resource res, String cacheKey) {
239+
def model = model((Class) scaffoldValue)
240+
def viewGenerator = new GStringTemplateEngine()
241+
Template t = viewGenerator.createTemplate(res.URL)
242+
243+
def contents = new FastStringWriter()
244+
t.make(model.asMap()).writeTo(contents)
245+
246+
def template = templateEngine.createTemplate(new ByteArrayResource(contents.toString().getBytes(templateEngine.gspEncoding), "view:$cacheKey"), !enableReload)
247+
def view = new GroovyPageView()
248+
view.setServletContext(getServletContext())
249+
view.setTemplate(template)
250+
view.setApplicationContext(getApplicationContext())
251+
view.setTemplateEngine(templateEngine)
252+
view.afterPropertiesSet()
253+
generatedViewCache.put(cacheKey, view)
178254
return view
179255
}
256+
257+
@Override
258+
void clearCache() {
259+
super.clearCache()
260+
generatedViewCache.clear()
261+
scaffoldValueCache.clear()
262+
}
180263
}

0 commit comments

Comments
 (0)