From e53f68a1909947a800929d74614e59d98a47f2f9 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Tue, 9 Dec 2025 06:46:42 +0200 Subject: [PATCH 1/4] 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 4fc0c43fe03eec1e1673a08e7628cfeafa4fceb5 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/4] 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 d76552e27..8ea0b2f05 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: { @@ -128,10 +131,15 @@ function ProfileForm(props: { team: TeamDetails }) { }) } rootClasses="team-profile__input" + disabled={formDisabled} />
- + Save changes From 5eace68406a83464eb8b76cda1aeef50ee915f5c 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/4] 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 036d512bfba6fc2d4fff90f66e2003339ebf263a 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/4] 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) {