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 +}
