add FEB purpose questions

This commit is contained in:
matthewswspence 2025-03-03 16:11:50 -06:00
parent c821b20e8e
commit 4004a2f735
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
8 changed files with 191 additions and 61 deletions

View file

@ -3,7 +3,6 @@ import { showElement } from './helpers.js';
export const domain_purpose_choice_callbacks = { export const domain_purpose_choice_callbacks = {
'new': { 'new': {
callback: function(value, element) { callback: function(value, element) {
console.log("Callback for new")
//show the purpose details container //show the purpose details container
showElement(element); showElement(element);
// change just the text inside the em tag // change just the text inside the em tag
@ -15,11 +14,10 @@ export const domain_purpose_choice_callbacks = {
'evidence user need for this new domain. ' + 'evidence user need for this new domain. ' +
'<span class="usa-label--required">*</span>'; '<span class="usa-label--required">*</span>';
}, },
element: document.getElementById('domain-purpose-details-container') element: document.getElementById('purpose-details-container')
}, },
'redirect': { 'redirect': {
callback: function(value, element) { callback: function(value, element) {
console.log("Callback for redirect")
// show the purpose details container // show the purpose details container
showElement(element); showElement(element);
// change just the text inside the em tag // change just the text inside the em tag
@ -27,11 +25,10 @@ export const domain_purpose_choice_callbacks = {
labelElement.innerHTML = 'Explain why a redirect is necessary. ' + labelElement.innerHTML = 'Explain why a redirect is necessary. ' +
'<span class="usa-label--required">*</span>'; '<span class="usa-label--required">*</span>';
}, },
element: document.getElementById('domain-purpose-details-container') element: document.getElementById('purpose-details-container')
}, },
'other': { 'other': {
callback: function(value, element) { callback: function(value, element) {
console.log("Callback for other")
// Show the purpose details container // Show the purpose details container
showElement(element); showElement(element);
// change just the text inside the em tag // change just the text inside the em tag
@ -39,6 +36,6 @@ export const domain_purpose_choice_callbacks = {
labelElement.innerHTML = 'Describe how this domain will be used. ' + labelElement.innerHTML = 'Describe how this domain will be used. ' +
'<span class="usa-label--required">*</span>'; '<span class="usa-label--required">*</span>';
}, },
element: document.getElementById('domain-purpose-details-container') element: document.getElementById('purpose-details-container')
} }
} }

View file

@ -29,8 +29,8 @@ hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-namin
hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks);
hookupYesNoListener("purpose-has_timeframe", "domain-timeframe-details-container", null); hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null);
hookupYesNoListener("purpose-is_interagency_initiative", "domain-interagency-initaitive-details-container", null); hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null);
initializeUrbanizationToggle(); initializeUrbanizationToggle();
@ -56,4 +56,4 @@ initFormErrorHandling();
// Init the portfolio new member page // Init the portfolio new member page
initPortfolioMemberPageRadio(); initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle(); initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners(); initAddNewMemberPageListeners();

View file

@ -1,12 +1,17 @@
from django import forms from django import forms
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm, RegistrarForm from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm
class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): class FEBPurposeOptionsForm(BaseDeletableRegistrarForm):
field_name = "feb_purpose_choice" field_name = "feb_purpose_choice"
form_choices = (("new", "Used for a new website"), ("redirect", "Used as a redirect for an existing website"), ("other", "Not for a website")) form_choices = (
("new", "Used for a new website"),
("redirect", "Used as a redirect for an existing website"),
("other", "Not for a website"),
)
feb_purpose_choice = forms.ChoiceField( feb_purpose_choice = forms.ChoiceField(
required=True, required=True,
@ -15,12 +20,13 @@ class FEBPurposeOptionsForm(BaseDeletableRegistrarForm):
error_messages={ error_messages={
"required": "This question is required.", "required": "This question is required.",
}, },
label = "Select one" label="Select one",
) )
class PurposeDetailsForm(BaseDeletableRegistrarForm): class PurposeDetailsForm(BaseDeletableRegistrarForm):
field_name="purpose" field_name = "purpose"
purpose = forms.CharField( purpose = forms.CharField(
label="Purpose", label="Purpose",
@ -39,6 +45,7 @@ class PurposeDetailsForm(BaseDeletableRegistrarForm):
error_messages={"required": "Describe how youll use the .gov domain youre requesting."}, error_messages={"required": "Describe how youll use the .gov domain youre requesting."},
) )
class FEBTimeFrameYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): class FEBTimeFrameYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm):
""" """
Form for determining whether the domain request comes with a target timeframe for launch. Form for determining whether the domain request comes with a target timeframe for launch.
@ -73,6 +80,7 @@ class FEBTimeFrameDetailsForm(BaseDeletableRegistrarForm):
error_messages={"required": "Provide details on your target timeframe."}, error_messages={"required": "Provide details on your target timeframe."},
) )
class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm):
""" """
Form for determining whether the domain request is part of an interagency initative. Form for determining whether the domain request is part of an interagency initative.
@ -92,11 +100,7 @@ class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoFor
class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm): class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm):
interagency_initiative_details = forms.CharField( interagency_initiative_details = forms.CharField(
label="interagency_initiative_details", label="interagency_initiative_details",
widget=forms.Textarea( widget=forms.Textarea(attrs={"aria-label": "Name the agencies that will be involved in this initiative."}),
attrs={
"aria-label": "Name the agencies that will be involved in this initiative."
}
),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
2000, 2000,
@ -104,4 +108,4 @@ class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm):
) )
], ],
error_messages={"required": "Name the agencies that will be involved in this initiative."}, error_messages={"required": "Name the agencies that will be involved in this initiative."},
) )

View file

@ -53,7 +53,7 @@ class DomainRequest(TimeStampedModel):
def get_status_label(cls, status_name: str): def get_status_label(cls, status_name: str):
"""Returns the associated label for a given status name""" """Returns the associated label for a given status name"""
return cls(status_name).label if status_name else None return cls(status_name).label if status_name else None
class FEBPurposeChoices(models.TextChoices): class FEBPurposeChoices(models.TextChoices):
WEBSITE = "website" WEBSITE = "website"
REDIRECT = "redirect" REDIRECT = "redirect"
@ -558,8 +558,6 @@ class DomainRequest(TimeStampedModel):
help_text="Other domain names the creator provided for consideration", help_text="Other domain names the creator provided for consideration",
) )
other_contacts = models.ManyToManyField( other_contacts = models.ManyToManyField(
"registrar.Contact", "registrar.Contact",
blank=True, blank=True,

View file

@ -7,6 +7,10 @@
<h2>What is the purpose of your requested domain?</h2> <h2>What is the purpose of your requested domain?</h2>
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %}
{# empty this block so it doesn't show on this page #}
{% endblock %}
{% block form_fields %} {% block form_fields %}
{% if requires_feb_questions %} {% if requires_feb_questions %}
<fieldset class="usa-fieldset margin-top-0 dotgov-domain-form"> <fieldset class="usa-fieldset margin-top-0 dotgov-domain-form">
@ -16,54 +20,54 @@
{{forms.3.management_form}} {{forms.3.management_form}}
{{forms.4.management_form}} {{forms.4.management_form}}
{{forms.5.management_form}} {{forms.5.management_form}}
<p class="usa-label"> <p class="margin-bottom-0 margin-top-1">
<em>Select One <span class="usa-label--required">*</span></em> <em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p> </p>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.feb_purpose_choice %} {% input_with_errors forms.0.feb_purpose_choice %}
{% endwith %} {% endwith %}
<div id="domain-purpose-details-container" class="conditional-panel display-none"> <div id="purpose-details-container" class="conditional-panel display-none">
<p class="usa-label"> <p class="usa-label">
<em>Provide details below <span class="usa-label--required">*</span></em> <em>Provide details below <span class="usa-label--required">*</span></em>
</p> </p>
{% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %}
{% input_with_errors forms.1.purpose %} {% input_with_errors forms.1.purpose %}
{% endwith %} {% endwith %}
<p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p> <p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p>
</div> </div>
<h2 class="margin-top-5">Do you have a target time frame for launching this domain?</h2> <h2>Do you have a target time frame for launching this domain?</h2>
<p class="usa-label"> <p class="margin-bottom-0 margin-top-1">
<em>Select One <span class="usa-label--required">*</span></em> <em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p> </p>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.2.has_timeframe %} {% input_with_errors forms.2.has_timeframe %}
{% endwith %} {% endwith %}
<div id="domain-timeframe-details-container" class="conditional-panel"> <div id="purpose-timeframe-details-container" class="conditional-panel">
<p> <p class="margin-bottom-0 margin-top-1">
<em>Provide details below <span class="usa-label--required">*</span></em> <em>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p> </p>
{% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %}
{% input_with_errors forms.3.time_frame_details %} {% input_with_errors forms.3.time_frame_details %}
{% endwith %} {% endwith %}
<p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p> <p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p>
</div> </div>
<h2>Will the domain name be used for an interagency initiative?</h2> <h2>Will the domain name be used for an interagency initiative?</h2>
<p> <p class="margin-bottom-0 margin-top-1">
<em>Select One <span class="usa-label--required">*</span></em> <em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p> </p>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.4.is_interagency_initiative %} {% input_with_errors forms.4.is_interagency_initiative %}
{% endwith %} {% endwith %}
<div id="domain-interagency-initaitive-details-container" class="conditional-panel"> <div id="purpose-interagency-initaitive-details-container" class="conditional-panel">
<p class="usa-label"> <p class="margin-bottom-0 margin-top-1">
<em>Provide details below <span class="usa-label--required">*</span></em> <em>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p> </p>
{% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} {% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %}
{% input_with_errors forms.5.interagency_initiative_details %} {% input_with_errors forms.5.interagency_initiative_details %}
{% endwith %} {% endwith %}
<p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p> <p class="usa-hint margin-top-0">Maximum 2000 characters allowed.</p>

View file

@ -14,10 +14,11 @@ from registrar.forms.domain_request_wizard import (
OtherContactsForm, OtherContactsForm,
RequirementsForm, RequirementsForm,
TribalGovernmentForm, TribalGovernmentForm,
PurposeDetailsForm,
AnythingElseForm, AnythingElseForm,
AboutYourOrganizationForm, AboutYourOrganizationForm,
) )
from registrar.forms.domainrequestwizard.purpose import PurposeDetailsForm
from registrar.forms.domain import ContactForm from registrar.forms.domain import ContactForm
from registrar.forms.portfolio import ( from registrar.forms.portfolio import (
PortfolioInvitedMemberForm, PortfolioInvitedMemberForm,

View file

@ -5,6 +5,7 @@ from django.utils import timezone
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from registrar.utility.constants import BranchChoices
from .common import MockSESClient, completed_domain_request # type: ignore from .common import MockSESClient, completed_domain_request # type: ignore
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -2521,6 +2522,124 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertContains(dotgov_page, "CityofEudoraKS.gov") self.assertContains(dotgov_page, "CityofEudoraKS.gov")
self.assertNotContains(dotgov_page, "medicare.gov") self.assertNotContains(dotgov_page, "medicare.gov")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_request_dotgov_domain_FEB_questions(self):
"""
Test that for a member of a federal executive branch portfolio with org feature on, the dotgov domain page
contains additional questions for OMB.
"""
agency, _ = FederalAgency.objects.get_or_create(
agency="US Treasury Dept",
federal_type=BranchChoices.EXECUTIVE,
)
portfolio, _ = Portfolio.objects.get_or_create(
creator=self.user,
organization_name="Test Portfolio",
organization_type=Portfolio.OrganizationChoices.FEDERAL,
federal_agency=agency,
)
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
intro_form = intro_page.forms[0]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_result = intro_form.submit()
# follow first redirect
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
portfolio_requesting_entity = intro_result.follow()
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# ---- REQUESTING ENTITY PAGE ----
requesting_entity_form = portfolio_requesting_entity.forms[0]
requesting_entity_form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = False
# test next button
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
requesting_entity_result = requesting_entity_form.submit()
# ---- CURRENT SITES PAGE ----
# Follow the redirect to the next form page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_sites_page = requesting_entity_result.follow()
current_sites_form = current_sites_page.forms[0]
current_sites_form["current_sites-0-website"] = "www.treasury.com"
# test saving the page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
current_sites_result = current_sites_form.submit()
# ---- DOTGOV DOMAIN PAGE ----
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
dotgov_page = current_sites_result.follow()
# separate out these tests for readability
self.feb_dotgov_domain_tests(dotgov_page)
# Now proceed with the actual test
domain_form = dotgov_page.forms[0]
domain_form["dotgov_domain-requested_domain"] = "test.gov"
domain_form["dotgov_domain-feb_naming_requirements"] = "True"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
domain_result = domain_form.submit()
# ---- PURPOSE PAGE ----
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
purpose_page = domain_result.follow()
self.feb_purpose_page_tests(purpose_page)
def feb_purpose_page_tests(self, purpose_page):
self.assertContains(purpose_page, "What is the purpose of your requested domain?")
# Make sure the purpose selector form is present
self.assertContains(purpose_page, "feb_purpose_choice")
# Make sure the purpose details form is present
self.assertContains(purpose_page, "purpose-details")
# Make sure the timeframe yes/no form is present
self.assertContains(purpose_page, "purpose-has_timeframe")
# Make sure the timeframe details form is present
self.assertContains(purpose_page, "purpose-time_frame_details")
# Make sure the interagency initiative yes/no form is present
self.assertContains(purpose_page, "purpose-is_interagency_initiative")
# Make sure the interagency initiative details form is present
self.assertContains(purpose_page, "purpose-interagency_initiative_details")
def feb_dotgov_domain_tests(self, dotgov_page):
# Make sure the dynamic example content doesn't show
self.assertNotContains(dotgov_page, "medicare.gov")
# Make sure the link at the top directs to OPM FEB guidance
self.assertContains(dotgov_page, "https://get.gov/domains/executive-branch-guidance/")
# Check for header of first FEB form
self.assertContains(dotgov_page, "Does this submission meet each domain naming requirement?")
# Check for label of second FEB form
self.assertContains(dotgov_page, "Provide details below")
# Check that the yes/no form was included
self.assertContains(dotgov_page, "feb_naming_requirements")
# Check that the details form was included
self.assertContains(dotgov_page, "feb_naming_requirements_details")
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_request_formsets(self): def test_domain_request_formsets(self):
"""Users are able to add more than one of some fields.""" """Users are able to add more than one of some fields."""

View file

@ -183,9 +183,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
return PortfolioDomainRequestStep if self.is_portfolio else Step return PortfolioDomainRequestStep if self.is_portfolio else Step
def requires_feb_questions(self) -> bool: def requires_feb_questions(self) -> bool:
# TODO: this is for testing, revert later return self.domain_request.is_feb() and flag_is_active_for_user(self.request.user, "organization_feature")
return True
# return self.domain_request.is_feb() and flag_is_active_for_user(self.request.user, "organization_feature")
@property @property
def prefix(self): def prefix(self):
@ -713,19 +711,20 @@ class DotgovDomain(DomainRequestWizard):
class Purpose(DomainRequestWizard): class Purpose(DomainRequestWizard):
template_name = "domain_request_purpose.html" template_name = "domain_request_purpose.html"
forms = [purpose.FEBPurposeOptionsForm, forms = [
purpose.PurposeDetailsForm, purpose.FEBPurposeOptionsForm,
purpose.FEBTimeFrameYesNoForm, purpose.PurposeDetailsForm,
purpose.FEBTimeFrameDetailsForm, purpose.FEBTimeFrameYesNoForm,
purpose.FEBInteragencyInitiativeYesNoForm, purpose.FEBTimeFrameDetailsForm,
purpose.FEBInteragencyInitiativeDetailsForm purpose.FEBInteragencyInitiativeYesNoForm,
] purpose.FEBInteragencyInitiativeDetailsForm,
]
def get_context_data(self): def get_context_data(self):
context= super().get_context_data() context = super().get_context_data()
context["requires_feb_questions"] = self.requires_feb_questions() context["requires_feb_questions"] = self.requires_feb_questions()
return context return context
def is_valid(self, forms_list: list) -> bool: def is_valid(self, forms_list: list) -> bool:
""" """
Expected order of forms_list: Expected order of forms_list:
@ -753,17 +752,29 @@ class Purpose(DomainRequestWizard):
feb_initiative_details_form.mark_form_for_deletion() feb_initiative_details_form.mark_form_for_deletion()
# we only care about the purpose details form in this case since it's used in both instances # we only care about the purpose details form in this case since it's used in both instances
return purpose_details_form.is_valid() return purpose_details_form.is_valid()
if not feb_purpose_options_form.is_valid(): if feb_purpose_options_form.is_valid():
option = feb_purpose_options_form.cleaned_data.get("feb_purpose_choice")
if option == "new":
purpose_details_form.fields["purpose"].error_messages = {
"required": "Explain why a new domain is required."
}
elif option == "redirect":
purpose_details_form.fields["purpose"].error_messages = {
"required": "Explain why a redirect is needed."
}
elif option == "other":
purpose_details_form.fields["purpose"].error_messages = {
"required": "Provide details on how this domain will be used."
}
# If somehow none of these are true use the default error message
else:
# Ensure details form doesn't throw errors if it's not showing # Ensure details form doesn't throw errors if it's not showing
purpose_details_form.mark_form_for_deletion() purpose_details_form.mark_form_for_deletion()
feb_timeframe_valid = feb_timeframe_yes_no_form.is_valid() feb_timeframe_valid = feb_timeframe_yes_no_form.is_valid()
feb_initiative_valid = feb_initiative_yes_no_form.is_valid() feb_initiative_valid = feb_initiative_yes_no_form.is_valid()
logger.debug(f"feb timeframe yesno: {feb_timeframe_yes_no_form.cleaned_data.get('has_timeframe')}")
logger.debug(f"FEB initiative yesno: {feb_initiative_yes_no_form.cleaned_data.get('is_interagency_initiative')}")
if not feb_timeframe_valid or not feb_timeframe_yes_no_form.cleaned_data.get("has_timeframe"): if not feb_timeframe_valid or not feb_timeframe_yes_no_form.cleaned_data.get("has_timeframe"):
# Ensure details form doesn't throw errors if it's not showing # Ensure details form doesn't throw errors if it's not showing
feb_timeframe_details_form.mark_form_for_deletion() feb_timeframe_details_form.mark_form_for_deletion()
@ -772,15 +783,11 @@ class Purpose(DomainRequestWizard):
# Ensure details form doesn't throw errors if it's not showing # Ensure details form doesn't throw errors if it's not showing
feb_initiative_details_form.mark_form_for_deletion() feb_initiative_details_form.mark_form_for_deletion()
for i, form in enumerate(forms_list):
logger.debug(f"Form {i} is marked for deletion: {form.form_data_marked_for_deletion}")
valid = all(form.is_valid() for form in forms_list if not form.form_data_marked_for_deletion) valid = all(form.is_valid() for form in forms_list if not form.form_data_marked_for_deletion)
return valid return valid
class OtherContacts(DomainRequestWizard): class OtherContacts(DomainRequestWizard):
template_name = "domain_request_other_contacts.html" template_name = "domain_request_other_contacts.html"
forms = [forms.OtherContactsYesNoForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm] forms = [forms.OtherContactsYesNoForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm]