Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { GrowthBook, GrowthBookProvider } from '@growthbook/growthbook-react'
import { Meta, StoryFn } from '@storybook/react'

import { featureFlags } from 'formsg-shared/constants'
import {
AttachmentSize,
BasicField,
Expand Down Expand Up @@ -209,3 +211,32 @@ export const Loading = Template.bind({})
Loading.parameters = {
msw: { handlers: { default: buildMswRoutes({}, 'infinite') } },
}

// Flag on: the whole card is clickable and shows a hover affordance (border,
// background, pencil tint). Hover only appears in the canvas (no pseudo-state
// addon); stories without this decorator render the flag-off, pencil-only card.
const editableCardsOnGrowthBook = new GrowthBook({
features: { [featureFlags.editableCardsMrfLogic]: { defaultValue: true } },
})

const withEditableCardsFlagOn = (Story: StoryFn) => (
<GrowthBookProvider growthbook={editableCardsOnGrowthBook}>
<Story />
</GrowthBookProvider>
)

export const WithLogicEditableCards = Template.bind({})
WithLogicEditableCards.parameters = {
msw: { handlers: { default: buildMswRoutes(FORM_WITH_LOGIC) } },
}
WithLogicEditableCards.decorators = [withEditableCardsFlagOn]

export const MobileWithLogicEditableCards = Template.bind({})
MobileWithLogicEditableCards.parameters = {
msw: { handlers: { default: buildMswRoutes(FORM_WITH_LOGIC) } },
viewport: {
defaultViewport: 'mobile1',
},
chromatic: { viewports: [viewports.xs] },
}
MobileWithLogicEditableCards.decorators = [withEditableCardsFlagOn]
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ type AdminLogicStore = {
}
| { state: AdminEditLogicState.EditingLogic; logicId: LogicDto['_id'] }
| null
pendingSwitchTo: LogicDto['_id'] | null
requestSwitchTo: (target: LogicDto['_id']) => void
cancelPendingSwitch: () => void
completeSave: () => void
}

const INITIAL_STATE = {
createOrEditData: null,
pendingSwitchTo: null,
}

export const isCreatingStateSelector = (state: AdminLogicStore) =>
Expand All @@ -41,9 +46,22 @@ export const setToEditingSelector = (state: AdminLogicStore) =>
export const setToInactiveSelector = (state: AdminLogicStore) =>
state.setToInactive

export const pendingSwitchToSelector = (state: AdminLogicStore) =>
state.pendingSwitchTo

export const requestSwitchToSelector = (state: AdminLogicStore) =>
state.requestSwitchTo

export const cancelPendingSwitchSelector = (state: AdminLogicStore) =>
state.cancelPendingSwitch

export const completeSaveSelector = (state: AdminLogicStore) =>
state.completeSave

export const useAdminLogicStore = create<AdminLogicStore>()(
devtools((set) => ({
devtools((set, get) => ({
createOrEditData: null,
pendingSwitchTo: null,
setToCreating: () =>
set({
createOrEditData: {
Expand All @@ -59,5 +77,22 @@ export const useAdminLogicStore = create<AdminLogicStore>()(
}),
setToInactive: () => set({ createOrEditData: null }),
reset: () => set(INITIAL_STATE),
requestSwitchTo: (target) => set({ pendingSwitchTo: target }),
cancelPendingSwitch: () => set({ pendingSwitchTo: null }),
// Complete a pending switch if one was requested, else collapse the card.
completeSave: () => {
const pending = get().pendingSwitchTo
if (pending !== null) {
set({
createOrEditData: {
state: AdminEditLogicState.EditingLogic,
logicId: pending,
},
pendingSwitchTo: null,
})
} else {
set({ createOrEditData: null })
}
},
})),
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'
import { LogicDto } from 'formsg-shared/types'

import {
setToInactiveSelector,
cancelPendingSwitchSelector,
completeSaveSelector,
useAdminLogicStore,
} from '../../../adminLogicStore'
import { useLogicMutations } from '../../../mutations'
Expand All @@ -22,16 +23,19 @@ export const ActiveLogicBlock = ({
}: ActiveLogicBlockProps): JSX.Element => {
const { t } = useTranslation()
const { updateLogicMutation } = useLogicMutations()
const setToInactive = useAdminLogicStore(setToInactiveSelector)
const completeSave = useAdminLogicStore(completeSaveSelector)
const cancelPendingSwitch = useAdminLogicStore(cancelPendingSwitchSelector)
const handleSubmit = useCallback(
(inputs: EditLogicInputs) =>
updateLogicMutation.mutate(
{ _id: logic._id, ...inputs },
{
onSuccess: () => setToInactive(),
onSuccess: completeSave,
// Drop any pending switch so a failed save can't redirect a later one.
onError: cancelPendingSwitch,
},
),
[logic._id, setToInactive, updateLogicMutation],
[logic._id, completeSave, cancelPendingSwitch, updateLogicMutation],
)

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Stack } from '@chakra-ui/react'
Expand All @@ -7,6 +7,10 @@ import { merge } from 'lodash'
import { LogicConditionState } from 'formsg-shared/types'

import {
cancelPendingSwitchSelector,
completeSaveSelector,
isCreatingStateSelector,
pendingSwitchToSelector,
setToInactiveSelector,
useAdminLogicStore,
} from '../../../adminLogicStore'
Expand All @@ -33,6 +37,7 @@ export const useEditLogicBlock = ({
onSubmit,
}: UseEditLogicBlockProps) => {
const setToInactive = useAdminLogicStore(setToInactiveSelector)
const cancelPendingSwitch = useAdminLogicStore(cancelPendingSwitchSelector)
const { logicableFields, idToFieldMap, formFields } = useAdminFormLogic()

const formMethods = useForm<EditLogicInputs>({
Expand Down Expand Up @@ -80,7 +85,12 @@ export const useEditLogicBlock = ({
[logicConditionBlocks.length, remove],
)

const handleSubmit = formMethods.handleSubmit((inputs) => onSubmit(inputs))
// An invalid submit cancels any pending switch so the card stays open; the
// Save-button path never has a pending switch, so that cancel is a no-op there.
const handleSubmit = formMethods.handleSubmit(
(inputs) => onSubmit(inputs),
cancelPendingSwitch,
)

return {
formMethods,
Expand Down Expand Up @@ -123,6 +133,34 @@ export const EditLogicBlock = ({
formFields,
} = useEditLogicBlock({ defaultValues, onSubmit })

const pendingSwitchTo = useAdminLogicStore(pendingSwitchToSelector)
const completeSave = useAdminLogicStore(completeSaveSelector)
const isCreatingState = useAdminLogicStore(isCreatingStateSelector)

// Auto-save when another logic block is clicked while this one is open. The
// editable-cards-mrf-logic flag gates this at the source (InactiveLogicBlock):
// when off, pendingSwitchTo is never set and this effect stays dormant. Shared
// by both the edit (ActiveLogicBlock) and create (NewLogicBlock) paths.
useEffect(() => {
if (pendingSwitchTo === null) return

// A save is already in flight; its onSuccess completes the switch.
// Submitting again would double-save and collapse the target.
if (isLoading) return

// A new block has nothing persisted yet, so it must always run validation
// (like the Add logic button): an incomplete new block blocks the switch.
// An existing block that wasn't touched can switch directly without a
// redundant save.
if (!isCreatingState && !formMethods.formState.isDirty) {
completeSave()
return
}

handleSubmit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingSwitchTo])

return (
<EditConditionWrapper ref={wrapperRef}>
<Stack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { BiPencil } from 'react-icons/bi'
import { Box, Divider, Stack, StackDivider, Text } from '@chakra-ui/react'
import { useFeatureIsOn } from '@growthbook/growthbook-react'

import { featureFlags } from 'formsg-shared/constants'
import { LogicDto, LogicType } from 'formsg-shared/types/form'

import IconButton from '~components/IconButton'

import {
createOrEditDataSelector,
requestSwitchToSelector,
setToEditingSelector,
useAdminLogicStore,
} from '../../../adminLogicStore'
Expand All @@ -29,9 +32,13 @@ export const InactiveLogicBlock = ({
const { idToFieldMap } = useAdminFormLogic()
const setToEditing = useAdminLogicStore(setToEditingSelector)
const stateData = useAdminLogicStore(createOrEditDataSelector)
const requestSwitchTo = useAdminLogicStore(requestSwitchToSelector)

// Prevent editing logic if some other logic block is being edited.
const isPreventEdit = useMemo(() => !!stateData, [stateData])
const isEditableCards = useFeatureIsOn(featureFlags.editableCardsMrfLogic)

// Flag off: block editing while another logic block is open (previous
// behaviour).
const isPreventEdit = !isEditableCards && !!stateData

const renderThenContent = useMemo(() => {
if (!idToFieldMap) return null
Expand Down Expand Up @@ -89,25 +96,40 @@ export const InactiveLogicBlock = ({
}, [logic, idToFieldMap, t])

const handleClick = useCallback(() => {
if (isPreventEdit) {
if (stateData) {
// Another logic block is open: auto-save it and switch here when the flag
// is on; otherwise editing is blocked.
if (isEditableCards) {
requestSwitchTo(logic._id)
}
return
}
setToEditing(logic._id)
}, [isPreventEdit, logic._id, setToEditing])
}, [isEditableCards, stateData, logic._id, setToEditing, requestSwitchTo])

if (!idToFieldMap) return null

return (
<Box pos="relative">
<Box pos="relative" role={isEditableCards ? 'group' : undefined}>
<Box
w="100%"
textAlign="start"
borderRadius="4px"
bg="white"
border="1px solid"
borderColor="neutral.300"
cursor={isPreventEdit ? 'not-allowed' : 'auto'}
transitionProperty="common"
transitionDuration="normal"
cursor={
isEditableCards ? 'pointer' : isPreventEdit ? 'not-allowed' : 'auto'
}
aria-disabled={isPreventEdit}
_groupHover={
isEditableCards
? { borderColor: 'primary.500', bg: 'primary.100' }
: undefined
}
onClick={isEditableCards ? handleClick : undefined}
>
<Stack
spacing="1.5rem"
Expand Down Expand Up @@ -158,12 +180,14 @@ export const InactiveLogicBlock = ({
top={{ base: '0.5rem', md: '2rem' }}
right={{ base: '0.5rem', md: '2rem' }}
pos="absolute"
aria-label={t('features.adminForm.sidebar.logic.modals.delete.title')}
aria-label={t('features.adminForm.sidebar.logic.inactiveBlock.clickToEdit')}
variant="clear"
onClick={handleClick}
icon={<BiPencil fontSize="1.5rem" />}
cursor={isPreventEdit ? 'not-allowed' : 'pointer'}
aria-disabled={isPreventEdit}
color={isEditableCards ? 'neutral.500' : undefined}
_groupHover={isEditableCards ? { color: 'primary.500' } : undefined}
/>
</Box>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'

import {
setToInactiveSelector,
cancelPendingSwitchSelector,
completeSaveSelector,
useAdminLogicStore,
} from '../../../adminLogicStore'
import { useLogicMutations } from '../../../mutations'
Expand All @@ -19,13 +20,16 @@ export const NewLogicBlock = ({
}: NewLogicBlockProps): JSX.Element => {
const { t } = useTranslation()
const { createLogicMutation } = useLogicMutations()
const setToInactive = useAdminLogicStore(setToInactiveSelector)
const completeSave = useAdminLogicStore(completeSaveSelector)
const cancelPendingSwitch = useAdminLogicStore(cancelPendingSwitchSelector)
const handleSubmit = useCallback(
(inputs: EditLogicInputs) =>
createLogicMutation.mutate(inputs, {
onSuccess: () => setToInactive(),
onSuccess: completeSave,
// Drop any pending switch so a failed save can't redirect a later one.
onError: cancelPendingSwitch,
}),
[createLogicMutation, setToInactive],
[createLogicMutation, completeSave, cancelPendingSwitch],
)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const enSG: Logic = {
thenShow: 'then show',
thenDisableSubmission: 'then disable submission',
fieldRemoved: 'This field was deleted and has been removed from your logic',
clickToEdit: 'Click to edit',
},
fieldBadge: {
fieldLabel: '{{label}} field',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Logic {
thenShow: string
thenDisableSubmission: string
fieldRemoved: string
clickToEdit: string
}
fieldBadge: {
fieldLabel: string
Expand Down
Loading