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/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 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/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) { diff --git a/packages/cyberstorm-theme/src/components/TextInput/TextInput.css b/packages/cyberstorm-theme/src/components/TextInput/TextInput.css index 95cff966b..818b73f41 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,12 +59,24 @@ --right-padding-bonus: var(--space-16); } - .text-input:hover { + .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: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); 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);