-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
#9355: fix dirty flag on Formik reinitialization regression #9361
Changes from 8 commits
fe1e4ba
7a1d703
d9362d3
a68a363
f68b35f
046ae87
188a0cd
c9cd797
173e353
1fb45d5
90b898d
3c0d975
d796735
754b463
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/* | ||
* Copyright (C) 2024 PixieBrix, Inc. | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published by | ||
* the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
import { expect, test } from "../../fixtures/testBase"; | ||
// @ts-expect-error -- https://youtrack.jetbrains.com/issue/AQUA-711/Provide-a-run-configuration-for-Playwright-tests-in-specs-with-fixture-imports-only | ||
import { test as base } from "@playwright/test"; | ||
import { ActivateModPage } from "../../pageObjects/extensionConsole/modsPage"; | ||
import { type PageEditorPage } from "end-to-end-tests/pageObjects/pageEditor/pageEditorPage"; | ||
import { type ConfigurationForm } from "../../pageObjects/pageEditor/configurationForm"; | ||
import { type ModListItem } from "../../pageObjects/pageEditor/modListingPanel"; | ||
|
||
const testModDefinitionName = "brick-configuration"; | ||
test.use({ modDefinitionNames: [testModDefinitionName] }); | ||
test("clear mod component changes", async ({ | ||
page, | ||
extensionId, | ||
modDefinitionsMap, | ||
newPageEditorPage, | ||
verifyModDefinitionSnapshot, | ||
}) => { | ||
const { id: modId } = modDefinitionsMap[testModDefinitionName]!; | ||
let pageEditorPage: PageEditorPage; | ||
let brickConfigurationPanel: ConfigurationForm; | ||
let modListItem: ModListItem; | ||
|
||
await test.step("Activate mods, and initialize page editor, and select the mod component", async () => { | ||
const modActivationPage = new ActivateModPage(page, extensionId, modId); | ||
await modActivationPage.goto(); | ||
await modActivationPage.clickActivateAndWaitForModsPageRedirect(); | ||
|
||
await page.goto("/"); | ||
pageEditorPage = await newPageEditorPage(page.url()); | ||
|
||
brickConfigurationPanel = pageEditorPage.brickConfigurationPanel; | ||
|
||
// Expand the mod | ||
await pageEditorPage.modListingPanel | ||
.getModListItemByName("Test mod - Brick Configuration") | ||
.select(); | ||
|
||
// Select the mod component | ||
modListItem = | ||
pageEditorPage.modListingPanel.getModListItemByName("Context menu item"); | ||
await modListItem.select(); | ||
|
||
// Change icon should not exist | ||
await expect(modListItem.unsavedChangesIcon).toHaveCount(0); | ||
}); | ||
|
||
await test.step("Modify the mod component name and expect change icon", async () => { | ||
await brickConfigurationPanel.fillField("Name", "A cool menu action"); | ||
|
||
// Reselect the mod component | ||
modListItem = | ||
pageEditorPage.modListingPanel.getModListItemByName("A cool menu action"); | ||
|
||
await expect(modListItem.unsavedChangesIcon).toBeVisible(); | ||
}); | ||
|
||
await test.step("Clear changes and expect the icon to go away", async () => { | ||
await modListItem.menuButton.click(); | ||
|
||
await modListItem.modComponentActionMenu.clearChangesButton.click(); | ||
|
||
const dialog = pageEditorPage.getByRole("dialog"); | ||
await dialog.getByRole("button", { name: "Clear Changes" }).click(); | ||
|
||
// Reselect the mod component | ||
modListItem = | ||
pageEditorPage.modListingPanel.getModListItemByName("Context menu item"); | ||
|
||
// Change icon should not exist | ||
await expect(modListItem.unsavedChangesIcon).toHaveCount(0); | ||
}); | ||
}); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,13 +15,12 @@ | |
* along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
import React, { useEffect, useMemo, useRef } from "react"; | ||
import React, { useCallback, useEffect, useMemo, useRef } from "react"; | ||
import { | ||
actions, | ||
actions as editorActions, | ||
} from "@/pageEditor/store/editor/editorSlice"; | ||
import { useDispatch, useSelector } from "react-redux"; | ||
import { useDebouncedCallback } from "use-debounce"; | ||
import { useDispatch, useSelector, useStore } from "react-redux"; | ||
import ErrorBoundary from "@/components/ErrorBoundary"; | ||
// eslint-disable-next-line no-restricted-imports -- TODO: Fix over time | ||
import { Formik } from "formik"; | ||
|
@@ -37,39 +36,76 @@ import IntegrationsSliceModIntegrationsContextAdapter from "@/integrations/store | |
import { assertNotNullish } from "@/utils/nullishUtils"; | ||
import useRegisterDraftModInstanceOnAllFrames from "@/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames"; | ||
import { usePreviousValue } from "@/hooks/usePreviousValue"; | ||
import type { EditorRootState } from "@/pageEditor/store/editor/pageEditorTypes"; | ||
|
||
// CHANGE_DETECT_DELAY_MILLIS should be low enough so that sidebar gets updated in a reasonable amount of time, but | ||
// high enough that there isn't an entry lag in the page editor | ||
const CHANGE_DETECT_DELAY_MILLIS = 100; | ||
const REDUX_SYNC_WAIT_MILLIS = 500; | ||
|
||
/** | ||
* Returns callback to generate the current key to force reinitialization of Formik form. | ||
*/ | ||
function useGetFormReinitializationKey(): () => string { | ||
const store = useStore<EditorRootState>(); | ||
|
||
return useCallback(() => { | ||
const state = store.getState(); | ||
const editorUpdateKey = selectEditorUpdateKey(state); | ||
grahamlangford marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const activeModComponentFormState = | ||
selectActiveModComponentFormState(state); | ||
|
||
assertNotNullish( | ||
activeModComponentFormState, | ||
"ModComponentEditorPane requires activeModComponentFormState", | ||
); | ||
|
||
return `${activeModComponentFormState.uuid}-${activeModComponentFormState.installed}-${editorUpdateKey}`; | ||
}, [store]); | ||
} | ||
|
||
const EditorPaneContent: React.VoidFunctionComponent<{ | ||
modComponentFormState: ModComponentFormState; | ||
}> = ({ modComponentFormState }) => { | ||
const dispatch = useDispatch(); | ||
const getFormReinitializationKey = useGetFormReinitializationKey(); | ||
const previousKey = useRef<string | null>(getFormReinitializationKey()); | ||
|
||
// XXX: anti-pattern: callback to update the redux store based on the formik state | ||
const syncReduxState = useDebouncedCallback( | ||
grahamlangford marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// XXX: anti-pattern: callback to update the redux store based on the Formik state. | ||
// Don't use useDebounceCallback because Effect component is already debounced | ||
const syncReduxState = useCallback( | ||
(values: ModComponentFormState) => { | ||
dispatch( | ||
editorActions.setModComponentFormState({ | ||
modComponentFormState: values, | ||
includesNonFormikChanges: false, | ||
dirty: true, | ||
}), | ||
); | ||
dispatch(actions.checkActiveModComponentAvailability()); | ||
const currentKey = getFormReinitializationKey(); | ||
|
||
// Don't sync on the first call after the reinitialization key changes because the call would cause | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See the failing CI test. I think our options are:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I ended up going with approach 1. With approach 2, there's a corner case where normalizations get overwritten if the user's next change is not via Formik There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Latest changes have a blended approach - it updates the form state but does not update the dirty flag |
||
// the form to be marked dirty even if there are no changes. | ||
// | ||
// NOTE: there are some editor components that contain useEffect calls that perform normalization on mount. | ||
// Therefore, in some cases, the first syncReduxState call will include changes, but we don't want to show | ||
// the unsaved change icon as they aren't functional changes | ||
// | ||
// See: https://github.com/pixiebrix/pixiebrix-extension/issues/9355 | ||
if (previousKey.current === currentKey) { | ||
dispatch( | ||
editorActions.setModComponentFormState({ | ||
modComponentFormState: values, | ||
includesNonFormikChanges: false, | ||
dirty: true, | ||
}), | ||
); | ||
|
||
dispatch(actions.checkActiveModComponentAvailability()); | ||
} | ||
|
||
previousKey.current = currentKey; | ||
}, | ||
REDUX_SYNC_WAIT_MILLIS, | ||
{ trailing: true, leading: false }, | ||
[dispatch, getFormReinitializationKey, previousKey], | ||
); | ||
|
||
// XXX: effect should be refactored to a middleware that listens for selected mod component | ||
useEffect(() => { | ||
const messageContext = { | ||
modComponentId: modComponentFormState.uuid, | ||
modId: modComponentFormState.modMetadata | ||
? modComponentFormState.modMetadata.id | ||
: undefined, | ||
modId: modComponentFormState.modMetadata.id, | ||
}; | ||
dispatch(logActions.setContext({ messageContext })); | ||
}, [modComponentFormState.uuid, modComponentFormState.modMetadata, dispatch]); | ||
|
@@ -89,10 +125,12 @@ const EditorPaneContent: React.VoidFunctionComponent<{ | |
}; | ||
|
||
/** | ||
* Returns the active mod component form state. Responds to updates in the editor state for use with triggering rerenders. | ||
* Returns the initial mod component form state for the Formik form. Responds to updates in the editor state | ||
* for use with Formik reinitialization. | ||
*/ | ||
function useInitialValues(): ModComponentFormState { | ||
const editorUpdateKey = useSelector(selectEditorUpdateKey); | ||
const getCurrentFormReinitializationKey = useGetFormReinitializationKey(); | ||
|
||
const activeModComponentFormState = useSelector( | ||
selectActiveModComponentFormState, | ||
); | ||
|
@@ -103,7 +141,7 @@ function useInitialValues(): ModComponentFormState { | |
); | ||
|
||
// Key to force reinitialization of formik when user selects a different mod component from the sidebar | ||
const key = `${activeModComponentFormState.uuid}-${activeModComponentFormState.installed}-${editorUpdateKey}`; | ||
const key = getCurrentFormReinitializationKey(); | ||
const prevKey = usePreviousValue(key); | ||
const activeModComponentFormStateRef = useRef(activeModComponentFormState); | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should probably have
role="status"
, but PlaywrightgetByRole
didn't seem to be working for that