This is an automated email from the ASF dual-hosted git repository.

codeconsole pushed a commit to branch feat/feature-relationship-metadata
in repository https://gitbox.apache.org/repos/asf/grails-forge-ui.git

commit 601fa4ea4e8548d6b7687c5db8b7d3644afe1ff2
Author: Scott Murphy Heiberg <[email protected]>
AuthorDate: Sat Jul 18 18:45:36 2026 -0700

    Act on the Forge API's feature relationship metadata
    
    The Forge API now reports two relationship fields per feature:
    dependentFeatures (features a selection adds automatically server-side)
    and oneOfGroup (features sharing a group are mutually exclusive). The
    feature selector previously showed neither: implied features were
    invisible in the selection, and conflicting features could be selected
    together only to fail at generation time.
    
    A featureRelations helper expands a selection transitively, and the
    selector now:
    
    - labels implied feature cards 'included automatically by <feature>'
      and makes them non-toggleable
    - greys out and disables cards whose selection - directly or through
      anything they would add automatically - would land in a one-of group
      already occupied, labelled 'unavailable with <feature>'
    - appends non-removable '(included)' chips for implied features to the
      Additional Selected Features row
    
    Features without the new metadata behave exactly as before.
---
 .../FeatureSelector/FeatureAvailable.jsx           | 18 +++++-
 .../components/FeatureSelector/FeatureSelected.jsx | 19 ++++++
 .../components/FeatureSelector/FeatureSelector.jsx | 22 ++++++-
 app/launch/src/helpers/featureRelations.js         | 70 ++++++++++++++++++++++
 4 files changed, 124 insertions(+), 5 deletions(-)

diff --git a/app/launch/src/components/FeatureSelector/FeatureAvailable.jsx 
b/app/launch/src/components/FeatureSelector/FeatureAvailable.jsx
index b911218..e5eb126 100644
--- a/app/launch/src/components/FeatureSelector/FeatureAvailable.jsx
+++ b/app/launch/src/components/FeatureSelector/FeatureAvailable.jsx
@@ -9,14 +9,25 @@ import {
 } from '@mui/material'
 
 const FeatureAvailable = ({ feature, toggleFeatures }) => {
+    const implied = Boolean(feature.impliedBy)
+    const disabled = Boolean(feature.conflictsWith)
+    const stateSuffix = implied
+        ? ` — included automatically by ${feature.impliedBy}`
+        : disabled
+        ? ` — unavailable with ${feature.conflictsWith}`
+        : ''
     return (
         <Card
             id={`mn-feature-${feature.name}`}
             className={`mn-feature-selection hoverable ${
                 feature.selected ? 'selected' : ''
-            }`}
+            }${implied ? ' implied' : ''}${disabled ? ' disabled' : ''}`}
             onClick={(e) => toggleFeatures(e, feature)}
-            style={{ cursor: 'pointer', position: 'relative' }}
+            style={{
+                cursor: implied || disabled ? 'not-allowed' : 'pointer',
+                opacity: disabled ? 0.5 : 1,
+                position: 'relative',
+            }}
         >
             <CardHeader
               title={
@@ -25,7 +36,8 @@ const FeatureAvailable = ({ feature, toggleFeatures }) => {
                     : feature.title) +
                 (feature.community != null && feature.community
                     ? ' (community)'
-                    : '')
+                    : '') +
+                stateSuffix
               }
               subheader={feature.description}
               titleTypographyProps={{
diff --git a/app/launch/src/components/FeatureSelector/FeatureSelected.jsx 
b/app/launch/src/components/FeatureSelector/FeatureSelected.jsx
index 3bb37bf..66017af 100644
--- a/app/launch/src/components/FeatureSelector/FeatureSelected.jsx
+++ b/app/launch/src/components/FeatureSelector/FeatureSelected.jsx
@@ -1,5 +1,6 @@
 // FeatureSelected.js
 import React, { useMemo } from 'react'
+import { impliedFeatures } from '../../helpers/featureRelations'
 import {
   useAvailableFeatures,
   useSelectedFeaturesHandlers,
@@ -63,10 +64,28 @@ export function FeatureSelectedList() {
     ))
   }, [selectedFeatureValues, onRemoveFeature, availableFeatures])
 
+  const impliedRows = useMemo(() => {
+    const implied = impliedFeatures(selectedFeatures, availableFeatures)
+    return Object.entries(implied).map(([name, byTitle]) => {
+      const feature = availableFeatures.find((f) => f.name === name)
+      return (
+        <div
+          key={`implied-${name}`}
+          className="chip implied"
+          style={{ marginRight: 10, opacity: 0.7 }}
+          title={`Included automatically by ${byTitle}`}
+        >
+          {feature ? feature.title : name} (included)
+        </div>
+      )
+    })
+  }, [selectedFeatures, availableFeatures])
+
   return (
     <div className="col s12">
       <h6>Additional Selected Features ({selectedFeatureValues.length})</h6>
       {sRows}
+      {impliedRows}
     </div>
   )
 }
diff --git a/app/launch/src/components/FeatureSelector/FeatureSelector.jsx 
b/app/launch/src/components/FeatureSelector/FeatureSelector.jsx
index 3965477..456233f 100644
--- a/app/launch/src/components/FeatureSelector/FeatureSelector.jsx
+++ b/app/launch/src/components/FeatureSelector/FeatureSelector.jsx
@@ -11,6 +11,11 @@ import {
   Icon,
 } from '@mui/material'
 import messages from '../../constants/messages.json'
+import {
+  conflictingWith,
+  impliedFeatures,
+  occupiedGroups,
+} from '../../helpers/featureRelations'
 import { ModalKeyboardHandler } from '../../helpers/ModalKeyboardHandler'
 import {
   useSelectedFeatures,
@@ -69,13 +74,23 @@ export const FeatureSelectorModal = ({ theme = 'light' }) 
=> {
 
   const selectedFeatureKeys = Object.keys(selectedFeatures)
   const availableFeatures = useMemo(() => {
+    const implied = impliedFeatures(selectedFeatures, features)
+    const groups = occupiedGroups(selectedFeatures, features)
     return features.map((feature) => {
+      const selected = selectedFeatureKeys.includes(feature.name)
+      const impliedBy = !selected ? implied[feature.name] : undefined
+      const conflict =
+        !selected && !impliedBy
+          ? conflictingWith(feature, groups, features)
+          : null
       return {
         ...feature,
-        selected: selectedFeatureKeys.includes(feature.name),
+        selected,
+        impliedBy,
+        conflictsWith: conflict,
       }
     })
-  }, [features, selectedFeatureKeys])
+  }, [features, selectedFeatures, selectedFeatureKeys])
 
   const searchResults = useMemo(() => {
     if (!search.length) {
@@ -101,6 +116,9 @@ export const FeatureSelectorModal = ({ theme = 'light' }) 
=> {
     if (event && event.preventDefault) {
       event.preventDefault()
     }
+    if (feature.conflictsWith || feature.impliedBy) {
+      return
+    }
     feature.selected ? onRemoveFeature(feature) : onAddFeature(feature)
   }
 
diff --git a/app/launch/src/helpers/featureRelations.js 
b/app/launch/src/helpers/featureRelations.js
new file mode 100644
index 0000000..24c4341
--- /dev/null
+++ b/app/launch/src/helpers/featureRelations.js
@@ -0,0 +1,70 @@
+// Feature relationship metadata from the Forge API:
+// - dependentFeatures: features a selection adds automatically server-side
+// - oneOfGroup: features sharing a group are mutually exclusive
+// These helpers expand a selection so the UI can show implied features and
+// disable cards whose selection would be rejected at generation time.
+
+const byName = (availableFeatures) =>
+  availableFeatures.reduce((map, f) => {
+    map[f.name] = f
+    return map
+  }, {})
+
+const transitiveDependents = (names, lookup) => {
+  const seen = new Set(names)
+  const queue = [...names]
+  while (queue.length) {
+    const feature = lookup[queue.shift()]
+    ;(feature?.dependentFeatures || []).forEach((dep) => {
+      if (!seen.has(dep)) {
+        seen.add(dep)
+        queue.push(dep)
+      }
+    })
+  }
+  return seen
+}
+
+// Map of implied-feature name -> title of the selected feature that implies it
+// (features implied by the selection but not explicitly selected themselves).
+export const impliedFeatures = (selectedFeatures, availableFeatures) => {
+  const lookup = byName(availableFeatures)
+  const implied = {}
+  Object.keys(selectedFeatures).forEach((name) => {
+    transitiveDependents([name], lookup).forEach((dep) => {
+      if (dep !== name && !(dep in selectedFeatures) && lookup[dep]) {
+        implied[dep] = lookup[name]?.title || name
+      }
+    })
+  })
+  return implied
+}
+
+// Map of oneOfGroup -> {name, title} of the effective (selected or implied)
+// feature occupying it.
+export const occupiedGroups = (selectedFeatures, availableFeatures) => {
+  const lookup = byName(availableFeatures)
+  const effective = transitiveDependents(Object.keys(selectedFeatures), lookup)
+  const groups = {}
+  effective.forEach((name) => {
+    const feature = lookup[name]
+    if (feature?.oneOfGroup) {
+      groups[feature.oneOfGroup] = { name, title: feature.title }
+    }
+  })
+  return groups
+}
+
+// The title of the effective feature that makes this card unselectable, or
+// null when the card can be selected. A card conflicts when it - or anything
+// it would add automatically - lands in a group occupied by another feature.
+export const conflictingWith = (feature, groups, availableFeatures) => {
+  const lookup = byName(availableFeatures)
+  for (const name of transitiveDependents([feature.name], lookup)) {
+    const group = lookup[name]?.oneOfGroup
+    if (group && groups[group] && groups[group].name !== name) {
+      return groups[group].title
+    }
+  }
+  return null
+}

Reply via email to