FunctionSettings Save Bar not hiding after save

Hello everyone,

I’m working on a custom discount app but faced a wierd issue. The Save Bar isn’t hiding after saving changes, changes are saved but seems like it still thinks the form is ‘dirty’. Can anyone help e figure what the issues is. Here is the code:


import { reactExtension,useApi, BlockStack,FunctionSettings,Section,Text,Form,NumberField,Heading} from "@shopify/ui-extensions-react/admin";
import { useState, useEffect } from "react";

const TARGET = "admin.discount-details.function-settings.render";
const METAFIELD_NAMESPACE = "$app:discounts--ui-extension";
const METAFIELD_KEY = "function-configuration";

const DEFAULT_FORM_STATE = {
   product: 0
}

export default reactExtension(TARGET, async (api) => 
{
    const existingDefinition = await getMetafieldDefinition(api.query);

    if (!existingDefinition) 
    {
        const metafieldDefinition = await createMetafieldDefinition(api.query);
    }
    return <App />;
});

function App() {
    const {
        applyExtensionMetafieldChange,
        i18n,
        formState,
        updateFormField,
        resetForm,
        initialFormState,
        loading
    } = useExtensionData();

    if (loading) {
        return <Text>{i18n.translate("loading")}</Text>;
    }

    return (
        <FunctionSettings onSave={applyExtensionMetafieldChange} onError={(errors) => { setError(errors[0]?.message) }}>
            <Heading size={6}>{i18n.translate("title")}</Heading>
            <Form onReset={resetForm}>
                <Section>
                    <BlockStack gap="base">
                        <BlockStack gap="base">
                            <NumberField
                            
                                label="Percentages"
                                name="product"
                                value={formState.product}
                                defaultValue={initialFormState.product}
                                onChange={(value) => 
                                {
                                    updateFormField('product', Number(value))
                                }}
                            >

                            </NumberField>
                        </BlockStack>
                    </BlockStack>
                </Section>
            </Form>
        </FunctionSettings>
    );
}

function useExtensionData() 
{
    const { applyMetafieldChange, i18n, data } = useApi(TARGET);

    const initialMetafields = data?.metafields || [];

    const [savedMetafields]                         = useState(initialMetafields);
    const [formState, setFormState]                 = useState({...DEFAULT_FORM_STATE});
    const [initialFormState, setInitialFormState]   = useState({...DEFAULT_FORM_STATE});
   
    const [loading, setLoading] = useState(false);

    const updateFormField = (field, value) => 
    {
        setFormState((prevState) => 
        {
            return {
                ...prevState, [field]:value
            }
        });
    };

    useEffect(() => 
    {
        async function fetchInitialData() 
        {
            setLoading(true);

            const metafieldValue = savedMetafields.find((metafield) => metafield.key === METAFIELD_KEY)?.value;

            if (metafieldValue)
            {
                try 
                {
                    const parsedValue = JSON.parse(metafieldValue);
                    
                    const initialState =  
                    {
                        ...DEFAULT_FORM_STATE, product: Number(parsedValue.product) ?? 0
                    }

                    setFormState(initialState);
                    setInitialFormState(initialState);
                }
                catch (error)
                {
                    console.log(error);
                }
            }

            setLoading(false);
        }

        fetchInitialData();
    },
    [initialMetafields]);

    async function applyExtensionMetafieldChange() 
    {
        try 
        {
            await applyMetafieldChange(
            {
                type: "updateMetafield",
                namespace: METAFIELD_NAMESPACE,
                key: METAFIELD_KEY,
                value: JSON.stringify(formState),
                valueType: "json",
            });

            setInitialFormState({...formState});

            return true; 
        }
        catch (error)
        {
            return false;
        }
    }

    function resetForm()
    {
        setFormState(initialFormState);

        return true;
    }

    return {
        applyExtensionMetafieldChange,
        i18n,
        formState,
        updateFormField,
        resetForm,
        initialFormState,
        loading
    };
}

async function getMetafieldDefinition(adminApiQuery) {
    const query = `#graphql
      query GetMetafieldDefinition {
        metafieldDefinitions(first: 1, ownerType: DISCOUNT, namespace: "${METAFIELD_NAMESPACE}", key: "${METAFIELD_KEY}") {
          nodes {
            id
          }
        }
      }
    `;

    const result = await adminApiQuery(query);

    return result?.data?.metafieldDefinitions?.nodes[0];
}
async function createMetafieldDefinition(adminApiQuery) {
    const definition = {
        access: {
            admin: "MERCHANT_READ_WRITE",
        },
        key: METAFIELD_KEY,
        name: "Discount Configuration",
        namespace: METAFIELD_NAMESPACE,
        ownerType: "DISCOUNT",
        type: "json",
    };

    const query = `#graphql
      mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
        metafieldDefinitionCreate(definition: $definition) {
          createdDefinition {
              id
            }
          }
        }
    `;

    const variables = { definition };
    const result = await adminApiQuery(query, { variables });

    return result?.data?.metafieldDefinitionCreate?.createdDefinition;
}

Tried almost everything but either I’m missing something obvious or it’s a bug in the react component.

Kind regards.

Hey @Angelico :waving_hand: - I think the save bar may be sticking since the metafield updates are set inside onSave, which may create a new pending change right as the host is persisting, so the page remains dirty.

I’d recommend placing the changes in each input’s onChange using applyMetafieldChange, and have onSave simply return true (or omit it if you don’t need extra side effects). Make sure NumberField’s defaultValue is String(initialFormState.product) and that the field has a stable name so Form can compare value vs defaultValue correctly.

After a successful save, you should be able to set initialFormState to the current formState so the value and defaultValue align and the save bar hides. If that doesn’t solve it or if I can clarify anything, ping me in the thread and I’ll take a closer look with you - hope this helps!

Hello Alan,

I really appreciate your feedback! Tried your recommendations, but without any luck. The Save bar just stays open, this is the refactored code:

import {
    reactExtension,
    useApi,
    BlockStack,
    FunctionSettings,
    Section,
    Text,
    Form,
    NumberField,
    Heading,
} from "@shopify/ui-extensions-react/admin";
import { useState, useEffect } from "react";

const TARGET = "admin.discount-details.function-settings.render";
const METAFIELD_NAMESPACE = "$app:discounts--ui-extension";
const METAFIELD_KEY = "function-configuration";

const DEFAULT_FORM_STATE = {
    product: 0,
};

export default reactExtension(TARGET, async (api) => {
    const existingDefinition = await getMetafieldDefinition(api.query);

    if (!existingDefinition) {
        await createMetafieldDefinition(api.query);
    }
    return <App />;
});

function App() {
    const {
        applyMetafieldChange,
        i18n,
        formState,
        updateFormField,
        resetForm,
        initialFormState,
        setInitialFormState,
        loading,
    } = useExtensionData();

    if (loading) {
        return <Text>{i18n.translate("loading")}</Text>;
    }

    const handleSave = async () => {
        setInitialFormState({ ...formState });
        return true;
    };

    return (
        <FunctionSettings
            onSave={handleSave}
            onError={(errors) => console.error(errors[0]?.message)}
        >
            <Heading size={6}>{i18n.translate("title")}</Heading>
            <Form onReset={resetForm}>
                <Section>
                    <BlockStack gap="base">
                        <NumberField
                            label="Percentages"
                            name="product"
                            value={String(formState.product)}
                            defaultValue={String(initialFormState.product)}
                            onChange={(value) => {
                                const numeric = Number(value);

                                updateFormField("product", numeric);

                                applyMetafieldChange({
                                    type: "updateMetafield",
                                    namespace: METAFIELD_NAMESPACE,
                                    key: METAFIELD_KEY,
                                    value: JSON.stringify({
                                        ...formState,
                                        product: numeric,
                                    }),
                                    valueType: "json",
                                });
                            }}
                        />
                    </BlockStack>
                </Section>
            </Form>
        </FunctionSettings>
    );
}

function useExtensionData() {
    const { applyMetafieldChange, i18n, data } = useApi(TARGET);

    const initialMetafields = data?.metafields || [];
    const [savedMetafields] = useState(initialMetafields);
    const [formState, setFormState] = useState({ ...DEFAULT_FORM_STATE });
    const [initialFormState, setInitialFormState] = useState({
        ...DEFAULT_FORM_STATE,
    });
    const [loading, setLoading] = useState(false);

    const updateFormField = (field, value) => {
        setFormState((prev) => ({ ...prev, [field]: value }));
    };

    useEffect(() => {
        async function fetchInitialData() {
            setLoading(true);
            const metafieldValue = savedMetafields.find(
                (m) => m.key === METAFIELD_KEY
            )?.value;

            if (metafieldValue) {
                try {
                    const parsedValue = JSON.parse(metafieldValue);
                    const initialState = {
                        ...DEFAULT_FORM_STATE,
                        product: Number(parsedValue.product) ?? 0,
                    };
                    setFormState(initialState);
                    setInitialFormState(initialState);
                } catch (error) {
                    console.error(error);
                }
            }
            setLoading(false);
        }
        fetchInitialData();
    }, [initialMetafields]);

    function resetForm() {
        setFormState(initialFormState);
        return true;
    }

    return {
        applyMetafieldChange,
        i18n,
        formState,
        updateFormField,
        resetForm,
        initialFormState,
        setInitialFormState,
        loading,
    };
}

async function getMetafieldDefinition(adminApiQuery) {
    const query = `#graphql
    query GetMetafieldDefinition {
      metafieldDefinitions(first: 1, ownerType: DISCOUNT, namespace: "${METAFIELD_NAMESPACE}", key: "${METAFIELD_KEY}") {
        nodes {
          id
        }
      }
    }
  `;
    const result = await adminApiQuery(query);
    return result?.data?.metafieldDefinitions?.nodes[0];
}

async function createMetafieldDefinition(adminApiQuery) {
    const definition = {
        access: {
            admin: "MERCHANT_READ_WRITE",
        },
        key: METAFIELD_KEY,
        name: "Discount Configuration",
        namespace: METAFIELD_NAMESPACE,
        ownerType: "DISCOUNT",
        type: "json",
    };

    const query = `#graphql
    mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
      metafieldDefinitionCreate(definition: $definition) {
        createdDefinition {
          id
        }
      }
    }
  `;
    const variables = { definition };
    const result = await adminApiQuery(query, { variables });
    return result?.data?.metafieldDefinitionCreate?.createdDefinition;
}

What I also observed initially, is that if I only change the name of the discount and save, the save Bar hides, but any changes in my custom field cause the form to remain always ‘dirty’ no matter what. Also, maybe I’m wrong, but initialFormState and formState seem to always differ no matter if I set it or not.

This is because you need to have an onSave on the Form component.

On the Form - use an empty function () => {}, and use applyMetafieldChange on the FunctionSettings onSave prop.

I’m hesitant really to make that recommendation until this issue is solved: Admin UI Extension not re-rendering and not updating data on Discount Navigation

I suppose it’d be useful to understand how applyMetafieldChange actually works. Does it create a change set that’s committed on onSave on the FunctionSettingscomponent?

Thanks @bkspace , tried this as well without any success. The form stays ‘dirty’ and it’s impossible to go to another menu without manually refreshing the page.

Hey guys,

Do you have any other suggestions to try? :slight_smile: