Skip to content

Fixes #39200 - Migrate Model Pages to React#10943

Merged
adamruzicka merged 1 commit into
theforeman:developfrom
pondrejk:reactmodels
May 7, 2026
Merged

Fixes #39200 - Migrate Model Pages to React#10943
adamruzicka merged 1 commit into
theforeman:developfrom
pondrejk:reactmodels

Conversation

@pondrejk
Copy link
Copy Markdown
Contributor

@pondrejk pondrejk commented Mar 31, 2026

Converting HW models pages, I did this for learning purposes looking at some more recent rewrites elsewhere in the project, so don't judge me too hard. In a way a follow up to #10910 that removed the PF3 button used in models.

Tests are AI assisted

name="model_hardware_model"
type="text"
ouiaId="model_hardware_model-input"
required
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L54 says only name is required

name="model_vendor_class"
type="text"
ouiaId="model_vendor_class-input"
required
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L54 says only name is required

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the Hardware Models UI pages (index/new/edit) from Rails views to React routes, aligning Models with other recently migrated SPA pages in the Foreman UI.

Changes:

  • Expand the React Models route export to include /models/new and /models/:id/edit, and wire it into the main route list.
  • Add new React pages/components for creating and editing models, including a shared ModelForm and associated JS tests.
  • Update Rails routing to serve React for models#new and models#edit, remove legacy ERB templates, and adjust Ruby/JS test coverage accordingly.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
webpack/assets/javascripts/react_app/routes/routes.js Spreads the Models route list into the main React routes array.
webpack/assets/javascripts/react_app/routes/test/snapshots/Routes.test.js.snap Updates snapshot to include new React routes for models new/edit.
webpack/assets/javascripts/react_app/routes/Models/index.js Changes Models route export from a single route to an array (index/new/edit).
webpack/assets/javascripts/react_app/routes/Models/constants.js Adds constants for new/edit model paths.
webpack/assets/javascripts/react_app/routes/Models/NewModelFormPage.js New React page for creating a model via API submit + breadcrumb.
webpack/assets/javascripts/react_app/routes/Models/ModelsPage/index.js Uses React Router navigation for create and edit links.
webpack/assets/javascripts/react_app/routes/Models/ModelForm.test.js Adds unit tests for the shared React model form.
webpack/assets/javascripts/react_app/routes/Models/ModelForm.js Introduces shared PF4 form component for new/edit model.
webpack/assets/javascripts/react_app/routes/Models/EditModelFormPage.js New React page for editing a model (loads initial values via API).
webpack/assets/javascripts/react_app/redux/actions/common/forms.js Adjusts default success toast to distinguish create vs update.
test/integration/model_js_test.rb Adds JS integration test coverage for deleting a model from the index.
test/controllers/models_controller_test.rb Removes controller tests tied to removed new/edit templates.
test/controllers/api/v2/models_controller_test.rb Adds API tests ensuring blank names are rejected on create/update.
config/routes.rb Routes models new/edit to React and keeps models index handled by React.
app/views/models/new.html.erb Removes legacy Rails view for model creation page.
app/views/models/edit.html.erb Removes legacy Rails view for model edit page.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread config/routes.rb Outdated
Comment on lines 393 to 401
resources :models, except: [:show, :index] do
member do
get 'edit', to: 'react#index'
end
collection do
get 'new', to: 'react#index', as: 'new'
get 'auto_complete_search'
end
end
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources :models, except: [:show, :index] still generates the default new/edit routes to ModelsController#new/#edit, but the corresponding templates were removed in this PR. This can lead to route conflicts (depending on route order) and/or MissingTemplate errors when hitting /models/new or /models/:id/edit. Align this with the filters migration pattern by excluding :new and :edit (and relying on the explicit react#index routes), or otherwise ensure the default routes are not reachable.

Copilot uses AI. Check for mistakes.
name="model_hardware_model"
type="text"
ouiaId="model_hardware_model-input"
required
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardware_model is marked required in the input, but the backend model only validates presence for name (hardware_model can be blank). With required set, the browser will block form submission unless this field is filled, changing behavior from the previous form.

Suggested change
required

Copilot uses AI. Check for mistakes.
name="model_vendor_class"
type="text"
ouiaId="model_vendor_class-input"
required
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vendor_class is also marked required, but the backend does not require it. This will prevent submitting the form with vendor_class left blank, which differs from the previous Rails form and model validations.

Suggested change
required

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +71
<FormGroup label={__('Name')} isRequired>
<TextInput
id="model_name"
name="model_name"
type="text"
ouiaId="model_name-input"
required
value={values.name}
onChange={handleChange('name')}
/>
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialValues can contain undefined values (your tests cover name: undefined), but the inputs pass value={values.name} etc. Passing undefined as an input value causes uncontrolled/controlled warnings and can break PatternFly inputs. Consider normalizing all field values to '' when storing state or when passing to TextInput/TextArea (e.g., value={values.name || ''}).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the test passing undefined for a name checks the negative scenario, asserts such value cannot be submitted

Comment on lines +29 to +38
useEffect(() => {
if (!response) return;

setValues(prevValues => ({
...prevValues,
name: response.name || '',
hardware_model: response.hardware_model || '',
vendor_class: response.vendor_class || '',
info: response.info || '',
}));
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect that hydrates values from response will overwrite any user edits made before the API call finishes (slow network, user starts typing quickly), causing lost input. Consider rendering the form only after the initial response is loaded, or only initializing state once (e.g., track an isLoaded/isDirty flag) so late responses don't clobber user changes.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +91
? '%s was successfully updated.'
: '%s was successfully created.',
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultSuccessToast constructs new user-facing strings ("%s was successfully updated." / "%s was successfully created.") without marking them for translation. Since this code is shared across the app and other messages in this module are wrapped in __(), these should also be translatable (e.g., wrap the full template strings in __).

Suggested change
? '%s was successfully updated.'
: '%s was successfully created.',
? __('%s was successfully updated.')
: __('%s was successfully created.'),

Copilot uses AI. Check for mistakes.
@pondrejk pondrejk force-pushed the reactmodels branch 3 times, most recently from eaafbc2 to 3af6799 Compare April 20, 2026 07:03
Copy link
Copy Markdown
Contributor

@Lukshio Lukshio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for the PR, code is clean, tests are looking good. Just some minor changes below.


const initialValues = useMemo(
() => ({
name: response.name || '',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be probably always set to response.name since it cannot be submitted without name. Or replaced with state in combination with sucessHandler from API call

isLoading
breadcrumbOptions={breadcrumbOptions}
>
{null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageLayout children cannot be null

Image

}) => {
const dispatch = useDispatch();
const history = useHistory();
const { response, status } = useAPI('get', `${MODELS_API_PATH}/${id}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to use APIActions.get() and set the values as a successHandler, instead of catching it by useMemo()

Comment on lines +32 to +34
useEffect(() => {
setValues(initialValues);
}, [initialValues]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial values should be set only once. Maybe I am missing something, but why it's set once in useState and then set once more here?

@pondrejk
Copy link
Copy Markdown
Contributor Author

@Lukshio @adamruzicka hi, I think I addressed all the comments, can you please revisit?

[response]
);
useEffect(() => {
setInitialValues(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setInitialValues(null);

This is not needed, state has already null as initial value - L31

},
})
);
}, [dispatch, id]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}, [dispatch, id]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Empty dependency array will run the effect only once when page will be mounted, that's what we need.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a problem with this when switching models trough the breadcrumb menu, the form didn't re-render after that

Comment on lines 94 to 123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageLayout should be used only once, but the child should be rendered conditionally.

@Lukshio
Copy link
Copy Markdown
Contributor

Lukshio commented Apr 22, 2026

The loading spinner is present on loaded page. Loading could be also implemented by showing form but with disabled fields, until loading finish.

image

@adamruzicka
Copy link
Copy Markdown
Contributor

Trying to submit a model which would cause a name collision just shows a toast saying Oh no! Something went wrong while submitting the form, the server returned the following error: Name has already been taken.

The actual api response is this

{
  "error": {"id":null,"errors":{"name":["has already been taken"]},"full_messages":["Name has already been taken"]}
}

Since we have all the data (the field and the actual error), could we show that in-line?

state => selectAPIResponse(state, modelNamesFetchKey),
shallowEqual
);
const existingNames =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in, preload up to 10000 hw model names to do name conflict checking on the frontend? That's potentially a lot of data to load and it can't guarantee conflicts won't arise. Couldn't we just display an inline error in case form submit fails?

Copy link
Copy Markdown
Contributor Author

@pondrejk pondrejk Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, but would it still be inline check if triggered only on submit? AI friend suggested some debounce search, wdyt?

Copy link
Copy Markdown
Contributor

@Lukshio Lukshio Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounce search sounds like a good solution. Just disable the submit button until the search is finished. It would also be nice to have a loading indicator to show that something is happening.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but would it still be inline check if triggered only on submit?

Originally by inline I meant "error shown close to the field that caused it", not necessarily "checks are happening as you type". I'd be fine with sticking with on submit validations.

Debounce search sounds like a good solution.

Do we have this pattern anywhere already? By replicating the validations on the frontend, we'd be losing the "backend is the one and only source of truth" stance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have something like this anywhere, but there are different inline validations that work as you type. So I guess the question is more if we want to start doing this.

I see this more as a convenience for users, so they don't have to find out about the problem only after submitting, the backend validation is still there on guard.

I'm also ok with going without this, but in such case I'd just keep the toast. Showing server error inline could imho be confusing, someone could think it is a buggy "as you type" check that didn't fire on time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have something like this anywhere, but there are different inline validations that work as you type. So I guess the question is more if we want to start doing this.

Eventually yes, but I'd say this should be approached systematically.

I'm also ok with going without this, but in such case I'd just keep the toast. Showing server error inline could imho be confusing, someone could think it is a buggy "as you type" check that didn't fire on time

Well, the old page just reset so anything is an improvement over that, as long as the error has some details and not just "oh no, it's on fire :^)"

With that being said, let's postpone it for later.

id="model_info"
name="model_info"
rows={7}
ouiaId="info-textarea"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ouiaId prop does not exists for TextArea component

<Stack hasGutter>
<StackItem>
{labelSkeleton}
<Skeleton height="36px" screenReaderText={__('Loading form')} />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Skeleton height="36px" screenReaderText={__('Loading form')} />
<Skeleton height="36px" screenreaderText={__('Loading form')} />

Fix typo

const labelSkeleton = <Skeleton height="14px" width="30%" />;

const ModelFormSkeleton = () => (
<Form isWidthLimited ouiaId="model-form-skeleton">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Form isWidthLimited ouiaId="model-form-skeleton">
<Form isWidthLimited id="model-form-skeleton">

I missed this one, Form does not have ouiaId

@Lukshio
Copy link
Copy Markdown
Contributor

Lukshio commented May 5, 2026

Changes looks good. Dev + QE ack.

Associated PRs:

#10974
SatelliteQE/robottelo#21166
SatelliteQE/airgun#2390

@pondrejk
Copy link
Copy Markdown
Contributor Author

pondrejk commented May 6, 2026

squashed after ack

@adamruzicka adamruzicka merged commit 8af49dd into theforeman:develop May 7, 2026
26 checks passed
@adamruzicka
Copy link
Copy Markdown
Contributor

Thank you @pondrejk & @Lukshio !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants