Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/cyberstorm-remix/app/settings/teams/Teams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
},
});

const teamNameFieldProps = strongForm.getFieldComponentProps("name");

const [open, setOpen] = useState(false);

return (
Expand All @@ -208,9 +210,10 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
</div>
<div className="create-team-form__input">
<label className="create-team-form__label" htmlFor="teamName">
Team Name
Team Name <span aria-hidden="true">*</span>
</label>
<NewTextInput
value={formInputs.name}
onChange={(v) =>
updateFormFieldState({
field: "name",
Expand All @@ -219,6 +222,8 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
}
placeholder={"MyCoolTeam"}
id="teamName"
required
{...teamNameFieldProps}
/>
</div>
</Modal.Body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export function MemberAddForm(props: {
},
});

const usernameFieldProps = strongForm.getFieldComponentProps("username");

return (
<Modal
open={open}
Expand All @@ -121,7 +123,7 @@ export function MemberAddForm(props: {
<div className="add-member-form__fields">
<div className="add-member-form__field add-member-form__username">
<label className="add-member-form__label" htmlFor="username">
Username
Username <span aria-hidden="true">*</span>
Copy link
Contributor

Choose a reason for hiding this comment

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

Thoughts on:

  • adding something like title="Required" to this element?
  • having a dedicated component for it to not have to repeat this over and over around the project?

</label>
<NewTextInput
name={"username"}
Expand All @@ -136,6 +138,8 @@ export function MemberAddForm(props: {
}}
rootClasses="add-member-form__username-input"
id="username"
required
{...usernameFieldProps}
/>
{error && <div className="add-member-form__error">{error}</div>}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {

import { type OutletContextShape } from "app/root";
import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders";
import { isTeamOwner } from "cyberstorm/utils/permissions";
import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm";
import "./Profile.css";

Expand Down Expand Up @@ -49,6 +50,8 @@ function ProfileForm(props: { team: TeamDetails }) {
const revalidator = useRevalidator();
const toast = useToast();

const formDisabled = !isTeamOwner(team.name, outletContext.currentUser);

function formFieldUpdateAction(
state: TeamDetailsEditRequestData,
action: {
Expand Down Expand Up @@ -128,10 +131,15 @@ function ProfileForm(props: { team: TeamDetails }) {
})
}
rootClasses="team-profile__input"
disabled={formDisabled}
/>
</div>
</div>
<NewButton rootClasses="team-profile__save" onClick={strongForm.submit}>
<NewButton
rootClasses="team-profile__save"
onClick={strongForm.submit}
disabled={formDisabled}
>
Save changes
</NewButton>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ function AddServiceAccountForm(props: {
},
});

const nicknameFieldProps = strongForm.getFieldComponentProps("nickname");

const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
Expand Down Expand Up @@ -197,46 +199,47 @@ function AddServiceAccountForm(props: {
</NewAlert>
</Modal.Body>
) : (
<Modal.Body>
<form
Copy link
Contributor

Choose a reason for hiding this comment

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

The form was here on purpose to allow submitting the form with enter on text input, as is expected. It shouldn't be removed as part of these changes.

The changes also makes the modal layout awkward:

Image

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, hmm, I'm not sure of the usage of the form elements with useStrongForm 🤔 But I'll check this out next time me work

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess this could be achieved by attaching event listeners to inputs, but on a bigged form that's a lot of boilerplate. Maybe the getFieldComponentProps could now be used to avoid that? But then we'd need to be able to tell different field types apart (input vs textarea vs select...).

Just a thought, some completely different approach might be better.

className="service-accounts__form"
onSubmit={(e) => {
e.preventDefault();
if (strongForm.isReady) {
strongForm.submit();
}
}}
>
<div>
Enter the nickname of the service account you wish to add to the
team <span>{props.teamName}</span>
</div>
<div className="service-accounts__nickname-input">
<NewTextInput
value={formInputs.nickname}
onChange={(e) => {
updateFormFieldState({
field: "nickname",
value: e.target.value,
});
}}
placeholder={"ExampleName"}
maxLength={32}
/>
<div className="service-accounts__nickname-input-max-length">
Max. 32 characters
<>
<Modal.Body>
<div className="service-accounts__form">
<div>
Enter the nickname of the service account you wish to add to the
team <span>{props.teamName}</span>
</div>
<label htmlFor="serviceAccountNickname">
Nickname <span aria-hidden="true">*</span>
</label>
<div className="service-accounts__nickname-input">
<NewTextInput
id="serviceAccountNickname"
value={formInputs.nickname}
onChange={(e) => {
updateFormFieldState({
field: "nickname",
value: e.target.value,
});
}}
placeholder={"ExampleName"}
maxLength={32}
required
{...nicknameFieldProps}
/>
<div className="service-accounts__nickname-input-max-length">
Max. 32 characters
</div>
</div>
{error && <NewAlert csVariant="danger">{error}</NewAlert>}
</div>
{error && <NewAlert csVariant="danger">{error}</NewAlert>}
</form>
</Modal.Body>
)}
{serviceAccountAdded ? null : (
<Modal.Footer>
<NewButton onClick={strongForm.submit} disabled={!strongForm.isReady}>
Add Service Account
</NewButton>
</Modal.Footer>
</Modal.Body>
<Modal.Footer>
<NewButton
onClick={strongForm.submit}
disabled={!strongForm.isReady}
>
Add Service Account
</NewButton>
</Modal.Footer>
</>
)}
</Modal>
);
Expand Down
101 changes: 95 additions & 6 deletions apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ParseError,
RequestBodyParseError,
Expand Down Expand Up @@ -57,20 +57,105 @@ export function useStrongForm<
const [submitOutput, setSubmitOutput] = useState<SubmissionOutput>();
const [submitError, setSubmitError] = useState<SubmissionError>();
const [inputErrors, setInputErrors] = useState<InputErrors>();
const [fieldInteractions, setFieldInteractions] = useState<
Partial<
Record<
keyof Inputs,
{
hasFocused: boolean;
hasBlurred: boolean;
}
>
>
>({});
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);

const isValueEmpty = (value: unknown) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the body of useStrongForm is getting rather long, so a helper function like this could be moved elsewhere.

if (typeof value === "string") {
return value.trim() === "";
}
return value === undefined || value === null;
};

const isReady = useMemo(() => {
if (!props.validators) return true;
for (const key in props.validators) {
const validator = props.validators[key];
const value = props.inputs[key];
const value = props.inputs[key as keyof Inputs];
// NOTE: Expand the checks as more validators are added
if (validator?.required) {
if (typeof value === "string" && value.trim() === "") return false;
if (value === undefined || value === null) return false;
if (validator?.required && isValueEmpty(value)) {
return false;
}
}
return true;
}, [props.inputs]);
}, [props.inputs, props.validators]);

const getFieldState = useCallback(
<K extends keyof Inputs>(field: K) => {
const validator = props.validators?.[field];
const value = props.inputs[field];
const isRequired = Boolean(validator?.required);
const rawInvalid = isRequired && isValueEmpty(value);
Copy link
Contributor

Choose a reason for hiding this comment

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

As part of my own task, I tried to build a URL validator for the team profile form on top of the changes on this PR. Having duplicate checks here and in isReady gets less ideal with each validator that is added. Maybe check if that can be avoided?

const interactions = fieldInteractions[field];
const hasFinishedInteraction =
Boolean(interactions?.hasFocused && interactions?.hasBlurred) ||
hasAttemptedSubmit;
const isInvalid = rawInvalid && hasFinishedInteraction;
return {
isRequired,
isInvalid,
};
},
[fieldInteractions, hasAttemptedSubmit, props.inputs, props.validators]
);

const markFieldInteraction = useCallback(
(field: keyof Inputs, type: "focus" | "blur") => {
setFieldInteractions((prev) => {
const current = prev[field] ?? { hasFocused: false, hasBlurred: false };
const next =
type === "focus"
? { ...current, hasFocused: true }
: { ...current, hasBlurred: true };
if (
current.hasFocused === next.hasFocused &&
current.hasBlurred === next.hasBlurred
) {
return prev;
}
return { ...prev, [field]: next };
});
},
[]
);

const getFieldInteractionProps = useCallback(
(field: keyof Inputs) => ({
onFocus: () => markFieldInteraction(field, "focus"),
onBlur: () => markFieldInteraction(field, "blur"),
}),
[markFieldInteraction]
);

const getFieldComponentProps = useCallback(
(field: keyof Inputs, options?: { disabled?: boolean }) => {
const fieldState = getFieldState(field);
const modifiers: ("invalid" | "disabled")[] = [];
if (fieldState.isInvalid) {
modifiers.push("invalid" as const);
}
if (options?.disabled) {
modifiers.push("disabled" as const);
}
const interactionProps = getFieldInteractionProps(field);
return {
...interactionProps,
"aria-invalid": fieldState.isInvalid,
csModifiers: modifiers,
};
},
[getFieldInteractionProps, getFieldState]
);

useEffect(() => {
if (refining || submitting) {
Expand Down Expand Up @@ -106,6 +191,7 @@ export function useStrongForm<
}, [props.inputs]);

const submit = async () => {
setHasAttemptedSubmit(true);
if (submitting) {
const error = new Error("Form is already submitting!");
if (props.onSubmitError) {
Expand Down Expand Up @@ -193,5 +279,8 @@ export function useStrongForm<
refineError,
inputErrors,
isReady,
getFieldState,
getFieldInteractionProps,
getFieldComponentProps,
};
}
Loading
Loading