Update Switch component onChange handler to use boolean callback (#9385)

* Update Switch component onChange handler to use boolean callback

- Remove synthetic event handling from Switch component
- Update onChange signature to (checked: boolean) => void
- Update all call points across the codebase:
  - PublicAccess.tsx: Updated handlers for indexing, showLastModified, and published switches
  - Security.tsx: Created individual handlers for all security preferences
  - Preferences.tsx: Created individual handlers for user preferences
  - CollectionForm.tsx: Added createSwitchRegister helper for react-hook-form compatibility
  - TemplatizeDialog: Updated publish handler
  - DocumentMenu.tsx: Added handlers for embeds and full-width toggles
  - Search.tsx: Updated SearchTitlesFilter handler
  - Application.tsx: Added createSwitchRegister helper
  - Details.tsx: Updated public branding handler
  - Features.tsx: Created individual handlers for seamless edit and commenting
  - OAuthClientForm.tsx: Added createSwitchRegister helper

- Maintained backward compatibility with react-hook-form
- Improved type safety and code clarity

* Fix ESLint floating promise errors

- Add void operator to onChange call in CollectionForm.tsx
- Add void operator to document.save call in DocumentMenu.tsx

These changes fix the @typescript-eslint/no-floating-promises errors
while maintaining the existing functionality.

* Fix Switch component onChange handlers in remaining files

- Updated DocumentCopy.tsx handlers to use boolean parameter
- Updated Notifications.tsx to use closure pattern for event types
- Updated SlackListItem.tsx to use closure pattern for event names
- All TypeScript errors resolved

* Refactor createSwitchRegister into utils/forms

- Created shared utility function in app/utils/forms.ts
- Removed duplicate implementations from CollectionForm, Application, and OAuthClientForm
- Updated all usage points to use the shared utility
- Improved TypeScript typing with Record<string, unknown>
- Fixed imports and removed unused variables
- Maintained existing functionality while reducing code duplication

* Fix TypeScript errors in createSwitchRegister utility

- Updated generic constraint from Record<string, unknown> to FieldValues
- Added Path import from react-hook-form for proper type safety
- Fixed parameter type from keyof TFormData to Path<TFormData>
- Improved type compatibility with react-hook-form's UseFormRegister

Resolves TypeScript compilation errors in CI pipeline.

* Remove unnecessary handlePublishChange callbacks

- Removed handlePublishChange wrapper in DocumentCopy.tsx
- Removed handlePublishChange wrapper in TemplatizeDialog/index.tsx
- Updated Switch components to use setPublish directly
- Simplified code by leveraging boolean callback from Switch component

Since Switch now passes boolean directly, no need for intermediate callbacks.

* Address review feedback: simplify callbacks and fix fullWidth behavior

1. DocumentCopy.tsx:
   - Remove handleRecursiveChange callback wrapper
   - Use setRecursive directly with Switch component

2. DocumentMenu.tsx:
   - Add void user.save() to persist user preference
   - Add document.fullWidth = checked for optimistic update behavior

Both changes leverage the boolean callback from Switch component properly.

* Update Security.tsx

* i18n

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-06-08 10:59:16 -04:00
committed by GitHub
parent 4ab2b22f7b
commit f3b4640c7a
16 changed files with 225 additions and 124 deletions

View File

@@ -22,6 +22,7 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { createSwitchRegister } from "~/utils/forms";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
@@ -114,7 +115,7 @@ export const CollectionForm = observer(function CollectionForm_({
}, [setFocus]);
const handleIconChange = useCallback(
(icon: string, color: string | null) => {
(icon: string, color: string) => {
if (icon !== values.icon) {
setFocus("name");
}
@@ -131,7 +132,6 @@ export const CollectionForm = observer(function CollectionForm_({
<Trans>
Collections are used to group documents and choose permissions
</Trans>
.
</Text>
<Flex gap={8}>
<Input
@@ -188,7 +188,7 @@ export const CollectionForm = observer(function CollectionForm_({
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
{...createSwitchRegister(register, "sharing")}
/>
)}
@@ -197,7 +197,7 @@ export const CollectionForm = observer(function CollectionForm_({
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
{...register("commenting")}
{...createSwitchRegister(register, "commenting")}
/>
)}

View File

@@ -46,20 +46,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
@@ -102,7 +88,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
onChange={setPublish}
/>
</Text>
)}
@@ -113,7 +99,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
onChange={setRecursive}
/>
</Text>
)}

View File

@@ -8,6 +8,7 @@ import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import { createSwitchRegister } from "~/utils/forms";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
@@ -116,7 +117,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
/>
{isCloudHosted && (
<Switch
{...register("published")}
{...createSwitchRegister(register, "published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
/>

View File

@@ -52,10 +52,10 @@ function PublicAccess({ document, share, sharedParent }: Props) {
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (event) => {
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: event.currentTarget.checked,
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
@@ -65,10 +65,10 @@ function PublicAccess({ document, share, sharedParent }: Props) {
);
const handleShowLastModifiedChanged = React.useCallback(
async (event) => {
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: event.currentTarget.checked,
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
@@ -78,10 +78,10 @@ function PublicAccess({ document, share, sharedParent }: Props) {
);
const handlePublishedChange = React.useCallback(
async (event) => {
async (checked: boolean) => {
try {
await share?.save({
published: event.currentTarget.checked,
published: checked,
});
} catch (err) {
toast.error(err.message);

View File

@@ -28,7 +28,7 @@ interface Props
/** Whether the switch is disabled */
disabled?: boolean;
/** Callback when the switch state changes */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onChange?: (checked: boolean) => void;
}
function Switch(
@@ -49,15 +49,10 @@ function Switch(
const handleCheckedChange = React.useCallback(
(checkedState: boolean) => {
if (onChange) {
// Create a synthetic event to maintain compatibility with existing code
const syntheticEvent = {
target: { id: props.id, name: props.name, checked: checkedState },
currentTarget: { checked: checkedState },
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
onChange(checkedState);
}
},
[onChange, props.id, props.name]
[onChange]
);
const component = (

View File

@@ -27,13 +27,6 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
document.collectionId ?? null
);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
@@ -72,7 +65,7 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={handlePublishChange}
onChange={setPublish}
/>
</Flex>
</ConfirmationDialog>

View File

@@ -234,6 +234,27 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onSelectTemplate,
});
const handleEmbedsToggle = React.useCallback(
(checked: boolean) => {
if (checked) {
document.enableEmbeds();
} else {
document.disableEmbeds();
}
},
[document]
);
const handleFullWidthToggle = React.useCallback(
(checked: boolean) => {
user.setPreference(UserPreference.FullWidthDocuments, checked);
void user.save();
document.fullWidth = checked;
void document.save({ fullWidth: checked });
},
[user, document]
);
return !isEmpty(can) ? (
<ContextMenu
{...menuState}
@@ -364,11 +385,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
label={t("Enable embeds")}
labelPosition="left"
checked={!document.embedsDisabled}
onChange={
document.embedsDisabled
? document.enableEmbeds
: document.disableEmbeds
}
onChange={handleEmbedsToggle}
/>
</Style>
)}
@@ -380,16 +397,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
label={t("Full width")}
labelPosition="left"
checked={document.fullWidth}
onChange={(ev) => {
const fullWidth = ev.currentTarget.checked;
user.setPreference(
UserPreference.FullWidthDocuments,
fullWidth
);
void user.save();
document.fullWidth = fullWidth;
void document.save();
}}
onChange={handleFullWidthToggle}
/>
</Style>
)}

View File

@@ -271,8 +271,8 @@ function Search() {
width={26}
height={14}
label={t("Search titles only")}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
handleFilterChange({ titleFilter: ev.target.checked });
onChange={(checked: boolean) => {
handleFilterChange({ titleFilter: checked });
}}
checked={titleFilter}
/>

View File

@@ -5,6 +5,7 @@ import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import Breadcrumb from "~/components/Breadcrumb";
@@ -22,6 +23,7 @@ import Tooltip from "~/components/Tooltip";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import { createSwitchRegister } from "~/utils/forms";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import { ActionRow } from "./components/ActionRow";
@@ -218,7 +220,10 @@ const Application = observer(function Application({ oauthClient }: Props) {
)}
border={false}
>
<Switch id="published" {...register("published")} />
<Switch
id="published"
{...createSwitchRegister(register, "published")}
/>
</SettingRow>
)}

View File

@@ -274,9 +274,7 @@ function Details() {
id={TeamPreference.PublicBranding}
name={TeamPreference.PublicBranding}
checked={publicBranding}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setPublicBranding(event.target.checked)
}
onChange={(checked: boolean) => setPublicBranding(checked)}
/>
</SettingRow>
)}

View File

@@ -15,16 +15,23 @@ function Features() {
const team = useCurrentTeam();
const { t } = useTranslation();
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
team.setPreference(
ev.target.name as TeamPreference,
inverted ? !ev.target.checked : ev.target.checked
);
const handleSeamlessEditChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.SeamlessEdit, !checked);
await team.save();
toast.success(t("Settings saved"));
};
},
[team, t]
);
const handleCommentingChange = React.useCallback(
async (checked: boolean) => {
team.setPreference(TeamPreference.Commenting, checked);
await team.save();
toast.success(t("Settings saved"));
},
[team, t]
);
return (
<Scene title={t("Features")} icon={<BeakerIcon />}>
@@ -46,7 +53,7 @@ function Features() {
id={TeamPreference.SeamlessEdit}
name={TeamPreference.SeamlessEdit}
checked={!team.getPreference(TeamPreference.SeamlessEdit)}
onChange={handlePreferenceChange(true)}
onChange={handleSeamlessEditChange}
/>
</SettingRow>
<SettingRow
@@ -60,7 +67,7 @@ function Features() {
id={TeamPreference.Commenting}
name={TeamPreference.Commenting}
checked={team.getPreference(TeamPreference.Commenting)}
onChange={handlePreferenceChange(false)}
onChange={handleCommentingChange}
/>
</SettingRow>
</Scene>

View File

@@ -138,15 +138,13 @@ function Notifications() {
}, 500);
const handleChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
await user.setNotificationEventType(
ev.target.name as NotificationEventType,
ev.target.checked
);
(eventType: NotificationEventType) => async (checked: boolean) => {
await user.setNotificationEventType(eventType, checked);
showSuccessMessage();
},
[user, showSuccessMessage]
);
const showSuccessNotice = window.location.search === "?success";
return (
@@ -196,7 +194,7 @@ function Notifications() {
id={option.event}
name={option.event}
checked={!!setting}
onChange={handleChange}
onChange={handleChange(option.event)}
/>
</SettingRow>
);

View File

@@ -49,16 +49,50 @@ function Preferences() {
[t]
);
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
user.setPreference(
ev.target.name as UserPreference,
inverted ? !ev.target.checked : ev.target.checked
);
const handleUseCursorPointerChange = React.useCallback(
async (checked: boolean) => {
user.setPreference(UserPreference.UseCursorPointer, checked);
await user.save();
toast.success(t("Preferences saved"));
};
},
[user, t]
);
const handleCodeBlockLineNumbersChange = React.useCallback(
async (checked: boolean) => {
user.setPreference(UserPreference.CodeBlockLineNumers, checked);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleSeamlessEditChange = React.useCallback(
async (checked: boolean) => {
user.setPreference(UserPreference.SeamlessEdit, !checked);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleRememberLastPathChange = React.useCallback(
async (checked: boolean) => {
user.setPreference(UserPreference.RememberLastPath, checked);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleEnableSmartTextChange = React.useCallback(
async (checked: boolean) => {
user.setPreference(UserPreference.EnableSmartText, checked);
await user.save();
toast.success(t("Preferences saved"));
},
[user, t]
);
const handleLanguageChange = React.useCallback(
async (language: string) => {
@@ -145,7 +179,7 @@ function Preferences() {
id={UserPreference.UseCursorPointer}
name={UserPreference.UseCursorPointer}
checked={user.getPreference(UserPreference.UseCursorPointer)}
onChange={handlePreferenceChange(false)}
onChange={handleUseCursorPointerChange}
/>
</SettingRow>
<SettingRow
@@ -158,7 +192,7 @@ function Preferences() {
id={UserPreference.CodeBlockLineNumers}
name={UserPreference.CodeBlockLineNumers}
checked={user.getPreference(UserPreference.CodeBlockLineNumers)}
onChange={handlePreferenceChange(false)}
onChange={handleCodeBlockLineNumbersChange}
/>
</SettingRow>
@@ -179,7 +213,7 @@ function Preferences() {
team.getPreference(TeamPreference.SeamlessEdit)
)
}
onChange={handlePreferenceChange(true)}
onChange={handleSeamlessEditChange}
/>
</SettingRow>
<SettingRow
@@ -193,7 +227,7 @@ function Preferences() {
id={UserPreference.RememberLastPath}
name={UserPreference.RememberLastPath}
checked={!!user.getPreference(UserPreference.RememberLastPath)}
onChange={handlePreferenceChange(false)}
onChange={handleRememberLastPathChange}
/>
</SettingRow>
<SettingRow
@@ -208,7 +242,7 @@ function Preferences() {
id={UserPreference.EnableSmartText}
name={UserPreference.EnableSmartText}
checked={!!user.getPreference(UserPreference.EnableSmartText)}
onChange={handlePreferenceChange(false)}
onChange={handleEnableSmartTextChange}
/>
</SettingRow>

View File

@@ -88,13 +88,6 @@ function Security() {
[team, showSuccessMessage]
);
const handleChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
await saveData({ [ev.target.id]: ev.target.checked });
},
[saveData]
);
const handleDefaultRoleChange = React.useCallback(
async (newDefaultRole: string) => {
await saveData({ defaultUserRole: newDefaultRole });
@@ -102,11 +95,68 @@ function Security() {
[saveData]
);
const handlePreferenceChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const handleGuestSigninChange = React.useCallback(
async (checked: boolean) => {
await saveData({ guestSignin: checked });
},
[saveData]
);
const handleSharingChange = React.useCallback(
async (checked: boolean) => {
await saveData({ sharing: checked });
},
[saveData]
);
const handleDocumentEmbedsChange = React.useCallback(
async (checked: boolean) => {
await saveData({ documentEmbeds: checked });
},
[saveData]
);
const handleMemberCollectionCreateChange = React.useCallback(
async (checked: boolean) => {
await saveData({ memberCollectionCreate: checked });
},
[saveData]
);
const handleMemberTeamCreateChange = React.useCallback(
async (checked: boolean) => {
await saveData({ memberTeamCreate: checked });
},
[saveData]
);
const handleMembersCanInviteChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[ev.target.id]: ev.target.checked,
[TeamPreference.MembersCanInvite]: checked,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleViewersCanExportChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[TeamPreference.ViewersCanExport]: checked,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleMembersCanDeleteAccountChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[TeamPreference.MembersCanDeleteAccount]: checked,
};
await saveData({ preferences });
},
@@ -114,8 +164,8 @@ function Security() {
);
const handleInviteRequiredChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const inviteRequired = ev.target.checked;
async (checked: boolean) => {
const inviteRequired = checked;
const newData = { ...data, inviteRequired };
if (inviteRequired) {
@@ -141,10 +191,9 @@ function Security() {
</ConfirmationDialog>
),
});
return;
} else {
await saveData(newData);
}
await saveData(newData);
},
[data, saveData, t, dialogs, team.signinMethods]
);
@@ -204,7 +253,7 @@ function Security() {
<Switch
id="guestSignin"
checked={data.guestSignin}
onChange={handleChange}
onChange={handleGuestSigninChange}
disabled={!env.EMAIL_ENABLED}
/>
</SettingRow>
@@ -218,7 +267,7 @@ function Security() {
<Switch
id={TeamPreference.MembersCanInvite}
checked={team.getPreference(TeamPreference.MembersCanInvite)}
onChange={handlePreferenceChange}
onChange={handleMembersCanInviteChange}
/>
</SettingRow>
{isCloudHosted && (
@@ -268,7 +317,11 @@ function Security() {
"When enabled, documents can be shared publicly on the internet by any member of the workspace"
)}
>
<Switch id="sharing" checked={data.sharing} onChange={handleChange} />
<Switch
id="sharing"
checked={data.sharing}
onChange={handleSharingChange}
/>
</SettingRow>
<SettingRow
label={t("Viewer document exports")}
@@ -280,7 +333,7 @@ function Security() {
<Switch
id={TeamPreference.ViewersCanExport}
checked={team.getPreference(TeamPreference.ViewersCanExport)}
onChange={handlePreferenceChange}
onChange={handleViewersCanExportChange}
/>
</SettingRow>
<SettingRow
@@ -293,7 +346,7 @@ function Security() {
<Switch
id={TeamPreference.MembersCanDeleteAccount}
checked={team.getPreference(TeamPreference.MembersCanDeleteAccount)}
onChange={handlePreferenceChange}
onChange={handleMembersCanDeleteAccountChange}
/>
</SettingRow>
<SettingRow
@@ -306,7 +359,7 @@ function Security() {
<Switch
id="documentEmbeds"
checked={data.documentEmbeds}
onChange={handleChange}
onChange={handleDocumentEmbedsChange}
/>
</SettingRow>
<SettingRow
@@ -319,7 +372,7 @@ function Security() {
<Switch
id="memberCollectionCreate"
checked={data.memberCollectionCreate}
onChange={handleChange}
onChange={handleMemberCollectionCreateChange}
/>
</SettingRow>
{isCloudHosted && (
@@ -331,7 +384,7 @@ function Security() {
<Switch
id="memberTeamCreate"
checked={data.memberTeamCreate}
onChange={handleChange}
onChange={handleMemberTeamCreateChange}
/>
</SettingRow>
)}

25
app/utils/forms.ts Normal file
View File

@@ -0,0 +1,25 @@
import { UseFormRegister, Path, FieldValues } from "react-hook-form";
/**
* Creates a switch register function that adapts react-hook-form's register
* to work with Switch components that use boolean callbacks instead of synthetic events.
*
* @param register - The register function from react-hook-form
* @param fieldName - The name of the form field to register
* @returns An object with the registration props adapted for Switch components
*/
export function createSwitchRegister<
TFormData extends FieldValues = FieldValues
>(register: UseFormRegister<TFormData>, fieldName: Path<TFormData>) {
const { onChange, ...rest } = register(fieldName);
return {
...rest,
onChange: (checked: boolean) => {
const syntheticEvent = {
target: { name: fieldName, value: checked, checked },
type: "change",
};
void onChange(syntheticEvent);
},
};
}

View File

@@ -26,13 +26,11 @@ type Props = {
function SlackListItem({ integration, collection }: Props) {
const { t } = useTranslation();
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.checked) {
integration.events = uniq([...integration.events, ev.target.name]);
const handleChange = (eventName: string) => async (checked: boolean) => {
if (checked) {
integration.events = uniq([...integration.events, eventName]);
} else {
integration.events = integration.events.filter(
(n) => n !== ev.target.name
);
integration.events = integration.events.filter((n) => n !== eventName);
}
await integration.save();
@@ -87,13 +85,13 @@ function SlackListItem({ integration, collection }: Props) {
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange}
onChange={handleChange("documents.publish")}
/>
<Switch
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}
onChange={handleChange}
onChange={handleChange("documents.update")}
/>
</Events>
</Popover>