From 41c21234adbba5b042529abfa4a26d4d4c3d0f93 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Tue, 9 Dec 2025 06:46:42 +0200 Subject: [PATCH 1/5] Improve StrongForm validation UX and text input styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add interaction-aware helpers to `useStrongForm` so inputs only go invalid after user focus/blur or submit, and expose ready-to-spread props for focus/blur/csModifiers/aria - refactor create-team, add-member, and add-service-account forms to use the new helper props instead of hand-rolled wiring - teach the theme’s TextInput component about invalid state colors so `csModifiers` visually reflect errors --- .../app/settings/teams/Teams.tsx | 7 +- .../teams/team/tabs/Members/MemberAddForm.tsx | 6 +- .../tabs/ServiceAccounts/ServiceAccounts.tsx | 79 +++++++------- .../utils/StrongForm/useStrongForm.ts | 101 ++++++++++++++++-- .../src/components/TextInput/TextInput.css | 12 +++ .../src/components/componentsColors.css | 4 + 6 files changed, 163 insertions(+), 46 deletions(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx index 274446080..a3a8402da 100644 --- a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx @@ -183,6 +183,8 @@ function CreateTeamForm(props: { config: () => RequestConfig }) { }, }); + const teamNameFieldProps = strongForm.getFieldComponentProps("name"); + const [open, setOpen] = useState(false); return ( @@ -208,9 +210,10 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
updateFormFieldState({ field: "name", @@ -219,6 +222,8 @@ function CreateTeamForm(props: { config: () => RequestConfig }) { } placeholder={"MyCoolTeam"} id="teamName" + required + {...teamNameFieldProps} />
diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx index 0d056d337..7a283eeef 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx @@ -95,6 +95,8 @@ export function MemberAddForm(props: { }, }); + const usernameFieldProps = strongForm.getFieldComponentProps("username"); + return (
{error &&
{error}
}
diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx index ab4fc1454..67d89971e 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx @@ -154,6 +154,8 @@ function AddServiceAccountForm(props: { }, }); + const nicknameFieldProps = strongForm.getFieldComponentProps("nickname"); + const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); if (!nextOpen) { @@ -197,46 +199,47 @@ function AddServiceAccountForm(props: { ) : ( - -
{ - e.preventDefault(); - if (strongForm.isReady) { - strongForm.submit(); - } - }} - > -
- Enter the nickname of the service account you wish to add to the - team {props.teamName} -
-
- { - updateFormFieldState({ - field: "nickname", - value: e.target.value, - }); - }} - placeholder={"ExampleName"} - maxLength={32} - /> -
- Max. 32 characters + <> + +
+
+ Enter the nickname of the service account you wish to add to the + team {props.teamName}
+ +
+ { + updateFormFieldState({ + field: "nickname", + value: e.target.value, + }); + }} + placeholder={"ExampleName"} + maxLength={32} + required + {...nicknameFieldProps} + /> +
+ Max. 32 characters +
+
+ {error && {error}}
- {error && {error}} - -
- )} - {serviceAccountAdded ? null : ( - - - Add Service Account - - + + + + Add Service Account + + + )} ); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts b/apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts index 39417d4a6..be898eb33 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ParseError, RequestBodyParseError, @@ -57,20 +57,105 @@ export function useStrongForm< const [submitOutput, setSubmitOutput] = useState(); const [submitError, setSubmitError] = useState(); const [inputErrors, setInputErrors] = useState(); + const [fieldInteractions, setFieldInteractions] = useState< + Partial< + Record< + keyof Inputs, + { + hasFocused: boolean; + hasBlurred: boolean; + } + > + > + >({}); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + + const isValueEmpty = (value: unknown) => { + 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( + (field: K) => { + const validator = props.validators?.[field]; + const value = props.inputs[field]; + const isRequired = Boolean(validator?.required); + const rawInvalid = isRequired && isValueEmpty(value); + 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) { @@ -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) { @@ -193,5 +279,8 @@ export function useStrongForm< refineError, inputErrors, isReady, + getFieldState, + getFieldInteractionProps, + getFieldComponentProps, }; } diff --git a/packages/cyberstorm-theme/src/components/TextInput/TextInput.css b/packages/cyberstorm-theme/src/components/TextInput/TextInput.css index 95cff966b..7ddf33bb6 100644 --- a/packages/cyberstorm-theme/src/components/TextInput/TextInput.css +++ b/packages/cyberstorm-theme/src/components/TextInput/TextInput.css @@ -27,6 +27,10 @@ &:focus-within .text-input__left-icon { --text-input-left-icon-color: var(--input-icon-color--focus); } + + &.text-input__wrapper--invalid .text-input__left-icon { + --text-input-left-icon-color: var(--input-icon-color--invalid); + } } .text-input[value] { @@ -55,6 +59,14 @@ --right-padding-bonus: var(--space-16); } + .text-input:where(.text-input--invalid), + .text-input:where(.text-input--invalid):hover, + .text-input:where(.text-input--invalid):focus-within { + --text-input-text-color: var(--input-text-color--invalid); + --text-input-background-color: var(--input-bg-color--invalid); + --text-input-border-color: var(--input-border-color--invalid); + } + .text-input:hover { --text-input-background-color: var(--input-bg-color--hover); --text-input-border-color: var(--input-border-color--hover); diff --git a/packages/cyberstorm-theme/src/components/componentsColors.css b/packages/cyberstorm-theme/src/components/componentsColors.css index 7f0604a23..7839e52ab 100644 --- a/packages/cyberstorm-theme/src/components/componentsColors.css +++ b/packages/cyberstorm-theme/src/components/componentsColors.css @@ -263,16 +263,20 @@ --input-bg-color--default: var(--color-nightsky-a4); --input-bg-color--focus: var(--color-nightsky-1); --input-bg-color--hover: var(--color-nightsky-a6); + --input-bg-color--invalid: var(--color-accent-red-2); --input-border-color--default: var(--color-nightsky-a10); --input-border-color--focus: var(--color-cyber-green-7); --input-border-color--hover: var(--color-nightsky-a10); + --input-border-color--invalid: var(--color-accent-red-7); --input-icon-color--default: var(--color-text-tertiary); --input-icon-color--focus: var(--color-text-secondary); --input-icon-color--hover: var(--color-text-tertiary); + --input-icon-color--invalid: var(--color-accent-red-8); --input-placeholder-color: var(--color-text-tertiary); --input-text-color--default: var(--color-text-secondary); --input-text-color--focus: var(--color-text-primary); --input-text-color--hover: var(--color-text-secondary); + --input-text-color--invalid: var(--color-text-primary); --kbd-bg-color--default: hsl(0deg 0 0% / 0); --kbd-border-color--default: var(--color-nightsky-a10); --kbd-text-color--default: var(--color-text-tertiary); From 7f922bc5a82896c7048e28302d4bfcb9b63018b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 8 Dec 2025 16:22:35 +0200 Subject: [PATCH 2/5] Disable team profile form for non-owner members Backend requires owner-level permissions so no point waste other member's time by letting them fiddle with the form. I considered creating entirely separate component for viewing the data, which would save us from having to set up strongForm for no reason, but given there's no reusable components, it would repeat a lot of markup and lead to poor maintainability. --- .../app/settings/teams/team/tabs/Profile/Profile.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx index ee9ed4cc0..11b321de1 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx @@ -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"; @@ -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: { @@ -131,10 +134,15 @@ function ProfileForm(props: { team: TeamDetails }) { }) } rootClasses="team-profile__input" + disabled={formDisabled} />
- + Save changes From be8d941c7d7461533be18d74462d86912e3a0185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 8 Dec 2025 16:30:06 +0200 Subject: [PATCH 3/5] Disable hover highlight styles from disabled text inputs Change the on-hover cursor when to indicate the input is disabled. --- .../src/components/TextInput/TextInput.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cyberstorm-theme/src/components/TextInput/TextInput.css b/packages/cyberstorm-theme/src/components/TextInput/TextInput.css index 7ddf33bb6..818b73f41 100644 --- a/packages/cyberstorm-theme/src/components/TextInput/TextInput.css +++ b/packages/cyberstorm-theme/src/components/TextInput/TextInput.css @@ -67,12 +67,16 @@ --text-input-border-color: var(--input-border-color--invalid); } - .text-input:hover { + .text-input:disabled { + cursor: not-allowed; + } + + .text-input:hover:not(:disabled) { --text-input-background-color: var(--input-bg-color--hover); --text-input-border-color: var(--input-border-color--hover); } - .text-input:focus-within { + .text-input:focus-within:not(:disabled) { --text-input-text-color: var(--input-text-color--focus); --text-input-background-color: var(--input-bg-color--focus); --text-input-border-color: var(--input-border-color--focus); From cec38943226791482380f323f1fdcbf2d4447d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 8 Dec 2025 16:53:14 +0200 Subject: [PATCH 4/5] Disable hover highlight styles from disabled buttons Remove pointer-events: none rule from disabled buttons so they show the default on-hover cursor for them. --- .../src/components/Button/Button.css | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/cyberstorm-theme/src/components/Button/Button.css b/packages/cyberstorm-theme/src/components/Button/Button.css index d9ae95aca..75ca95960 100644 --- a/packages/cyberstorm-theme/src/components/Button/Button.css +++ b/packages/cyberstorm-theme/src/components/Button/Button.css @@ -55,14 +55,14 @@ --button-text-color: var(--button-primary-text-color--default); --button-icon-color: var(--button-primary-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-primary-bg-color--hover); --button-text-color: var(--button-primary-text-color--hover); --button-icon-color: var(--button-primary-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-primary-bg-color--active); --button-text-color: var(--button-primary-text-color--active); --button-icon-color: var(--button-primary-icon-color--active); @@ -74,14 +74,14 @@ --button-text-color: var(--button-secondary-text-color--default); --button-icon-color: var(--button-secondary-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-secondary-bg-color--hover); --button-text-color: var(--button-secondary-text-color--hover); --button-icon-color: var(--button-secondary-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-secondary-bg-color--active); --button-text-color: var(--button-secondary-text-color--active); --button-icon-color: var(--button-secondary-icon-color--active); @@ -93,14 +93,14 @@ --button-text-color: var(--button-accent-text-color--default); --button-icon-color: var(--button-accent-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-accent-bg-color--hover); --button-text-color: var(--button-accent-text-color--hover); --button-icon-color: var(--button-accent-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-accent-bg-color--active); --button-text-color: var(--button-accent-text-color--active); --button-icon-color: var(--button-accent-icon-color--active); @@ -115,7 +115,7 @@ background: var(--button-special-background--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-text-color: var(--button-special-text-color--hover); --button-icon-color: var(--button-special-icon-color--hover); @@ -125,7 +125,7 @@ background: var(--button-special-background--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-text-color: var(--button-special-text-color--active); --button-icon-color: var(--button-special-icon-color--active); --button-border: var(--button-special-border--active); @@ -140,14 +140,14 @@ --button-text-color: var(--button-success-text-color--default); --button-icon-color: var(--button-success-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-success-bg-color--hover); --button-text-color: var(--button-success-text-color--hover); --button-icon-color: var(--button-success-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-success-bg-color--active); --button-text-color: var(--button-success-text-color--active); --button-icon-color: var(--button-success-icon-color--active); @@ -159,14 +159,14 @@ --button-text-color: var(--button-info-text-color--default); --button-icon-color: var(--button-info-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-info-bg-color--hover); --button-text-color: var(--button-info-text-color--hover); --button-icon-color: var(--button-info-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-info-bg-color--active); --button-text-color: var(--button-info-text-color--active); --button-icon-color: var(--button-info-icon-color--active); @@ -178,14 +178,14 @@ --button-text-color: var(--button-warning-text-color--default); --button-icon-color: var(--button-warning-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-warning-bg-color--hover); --button-text-color: var(--button-warning-text-color--hover); --button-icon-color: var(--button-warning-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-warning-bg-color--active); --button-text-color: var(--button-warning-text-color--active); --button-icon-color: var(--button-warning-icon-color--active); @@ -197,14 +197,14 @@ --button-text-color: var(--button-danger-text-color--default); --button-icon-color: var(--button-danger-icon-color--default); - &:where(:hover), + &:where(:hover:not(:disabled)), &[data-state="open"] { --button-background-color: var(--button-danger-bg-color--hover); --button-text-color: var(--button-danger-text-color--hover); --button-icon-color: var(--button-danger-icon-color--hover); } - &:where(:active) { + &:where(:active:not(:disabled)) { --button-background-color: var(--button-danger-bg-color--active); --button-text-color: var(--button-danger-text-color--active); --button-icon-color: var(--button-danger-icon-color--active); @@ -226,7 +226,6 @@ .button[disabled] { opacity: 0.5; - pointer-events: none; } .button:where(.button--only-icon) { From fdfd50d9418b05c3e68418605ee8b0e65ab09bff Mon Sep 17 00:00:00 2001 From: Oksamies Date: Sat, 20 Dec 2025 20:01:17 +0200 Subject: [PATCH 5/5] Add RequiredIndicator component and update forms to use it for required fields --- .../RequiredIndicator/RequiredIndicator.tsx | 20 +++++++++++++++++++ .../app/settings/teams/Teams.tsx | 3 ++- .../teams/team/tabs/Members/MemberAddForm.tsx | 3 ++- .../tabs/ServiceAccounts/ServiceAccounts.tsx | 3 ++- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 apps/cyberstorm-remix/app/commonComponents/RequiredIndicator/RequiredIndicator.tsx diff --git a/apps/cyberstorm-remix/app/commonComponents/RequiredIndicator/RequiredIndicator.tsx b/apps/cyberstorm-remix/app/commonComponents/RequiredIndicator/RequiredIndicator.tsx new file mode 100644 index 000000000..af9500194 --- /dev/null +++ b/apps/cyberstorm-remix/app/commonComponents/RequiredIndicator/RequiredIndicator.tsx @@ -0,0 +1,20 @@ +import { memo } from "react"; + +export interface RequiredIndicatorProps { + title?: string; + className?: string; +} + +export const RequiredIndicator = memo(function RequiredIndicator( + props: RequiredIndicatorProps +) { + const { title = "Required", className } = props; + + return ( + + ); +}); + +RequiredIndicator.displayName = "RequiredIndicator"; diff --git a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx index a3a8402da..ea06fa46d 100644 --- a/apps/cyberstorm-remix/app/settings/teams/Teams.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/Teams.tsx @@ -15,6 +15,7 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useReducer, useState } from "react"; import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; +import { RequiredIndicator } from "~/commonComponents/RequiredIndicator/RequiredIndicator"; import { type RequestConfig, teamCreate, @@ -210,7 +211,7 @@ function CreateTeamForm(props: { config: () => RequestConfig }) {
[] = [ { value: "member", label: "Member" }, @@ -123,7 +124,7 @@ export function MemberAddForm(props: {
{props.teamName}