Nested input metafield in run.graphql

Hi, I have a mostly working Discount Function with the Extension UI working. I can save the dollar amount, and parse that in the function. HOWEVER, I cannot figure out how to either use a nested metafield in the input variables section of the run.graphql or alternatively save the key/value pair “flat” i.e. not nested.

Here is my run.qraphql:

query RunInput($notcartLineTag: [String!]){

  cart {
    lines {
      id
      quantity
      cost {
        amountPerQuantity {
          amount
          currencyCode
        }
        subtotalAmount {
          amount
          currencyCode
        }
      }
      
      merchandise {
        __typename
        ...on ProductVariant {
            id
            product {
              two_for_tag: hasAnyTag(tags: ["2FOR25"])
              other_tag: hasAnyTag(tags: $notcartLineTag)
            }
        }
      }
    }
  }

  discountNode {
    metafield(namespace: "$app:example-discounts--ui-extension", key: "function-configuration") {
      value
    }
  }
}

I have temporarily added ‘not’ to the beginning of the variable to make it not bomb out.

Here is my input log:

{
  "cart": {
    "lines": [
      {
        "id": "gid://shopify/CartLine/0",
        "quantity": 2,
        "cost": {
          "amountPerQuantity": {
            "amount": "48.99",
            "currencyCode": "USD"
          },
          "subtotalAmount": {
            "amount": "97.98",
            "currencyCode": "USD"
          }
        },
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/43012925161650",
          "product": {
            "two_for_tag": true,
            "other_tag": false
          }
        }
      },
      {
        "id": "gid://shopify/CartLine/1",
        "quantity": 2,
        "cost": {
          "amountPerQuantity": {
            "amount": "48.99",
            "currencyCode": "USD"
          },
          "subtotalAmount": {
            "amount": "97.98",
            "currencyCode": "USD"
          }
        },
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/43012925128882",
          "product": {
            "two_for_tag": true,
            "other_tag": false
          }
        }
      }
    ]
  },
  "discountNode": {
    "metafield": {
      "value": "{\"cartLinePercentage\":25,\"cartLineTag\":{\"tag\":\"2FOR25\"},\"orderPercentage\":0,\"deliveryPercentage\":0,\"collectionIds\":[]}"
    }
  }
}

I cannot get the cartLineTag key “flat” like the cartLinePercentage key, in my DiscountFunctionSettings.jsx file:

The idea is to let Discount users add both the Dollar Amount (yes I know I should change cartLinePercentage but its working now) AND the Tag to identify products eligible for the 2 for 25 Discount:

Any help greatly appreciated, if I can get this working I will post a public repo with private stuff redacted so people can use this framework. The discount uses tags on products to reduce the price in a bundle of two and the products can have all different prices and the function urges customers to add one more if they have odd number of eligible items in their cart. I just need to get the UI Extension working so its more configurable by users.

I am using the React template from the documentation

Hi Floyd,

There’s a couple things you could check here - first off, in your UI extension, when you save the configuration, make sure you are saving the tag as a simple string, not as an object. For example:

  {
    "cartLinePercentage": 25,
    "cartLineTag": "2FOR25",
    "orderPercentage": 0,
    "deliveryPercentage": 0,
    "collectionIds": []
  }

instead of:

  {
    "cartLineTag": { "tag": "2FOR25" }
  }

In your DiscountFunctionSettings.jsx, when you save the config, make sure you are saving the tag as a string:

// BAD (nested)
setConfig({
  ...config,
  cartLineTag: { tag: tagValue }
});

// GOOD (flat)
setConfig({
  ...config,
  cartLineTag: tagValue
});

When you submit the configuration, the metafield will be saved as:

{
  "cartLineTag": "2FOR25"
}

In your function’s input query, always use jsonValue for the metafield:

query RunInput {
  cart {
    lines {
      id
      quantity
      // ... other fields
    }
  }
  discountNode {
    metafield(namespace: "$app:example-discounts--ui-extension", key: "function-configuration") {
      jsonValue
    }
  }
}

Then, in your function logic (JS or Rust), treat cartLineTag as a string, not as an object, eg for JS:

const config = input.discountNode.metafield?.jsonValue ?? {};
const tag = config.cartLineTag; // This is now a string

Hope this helps!

Thanks Liam but none of that works. I am using the Shopify supplied tutorial on the React UI for discounts, and there is some React magic that does not allow me to set the metafields directly.

Below is the DiscountFunctionsSettings,jsx:


  import {
    reactExtension,
    useApi,
    BlockStack,
    FunctionSettings,
    Section,
    Text,
    Form,
    NumberField,
    Box,
    InlineStack,
    Heading,
    TextField,
    Button,
    Icon,
    Link,
    Divider,
    Select,
  } from "@shopify/ui-extensions-react/admin";
  import { useState, useMemo, useEffect } from "react";

  const TARGET = "admin.discount-details.function-settings.render";

  export default reactExtension(TARGET, async (api) => {
    const existingDefinition = await getMetafieldDefinition(api.query);
    if (!existingDefinition) {
      // Create a metafield definition for persistence if no pre-existing definition exists
      const metafieldDefinition = await createMetafieldDefinition(api.query);

      if (!metafieldDefinition) {
        throw new Error("Failed to create metafield definition");
      }
    }
    return <App />;
  });

  function PercentageField({ label, defaultValue, value, onChange, name }) {
    return (
      <Box>
        <BlockStack gap="base">
          <NumberField
            label={label}
            name={name}
            value={Number(value)}
            defaultValue={String(defaultValue)}
            onChange={(value) => onChange(String(value))}
            suffix="$"
          />
        </BlockStack>
      </Box>
    );
  }

  function TagField({ label, defaultValue, value, onChange, name }) {
    return (
      <Box>
        <BlockStack gap="base">
          <TextField
            label={label}
            name={name}
            value={String(value)}
            defaultValue={String(defaultValue)}
            onChange={(value) => onChange(String(value))}
            suffix=""
          />
        </BlockStack>
      </Box>
    );
  }
  



  function AppliesToCollections({
    onClickAdd,
    onClickRemove,
    value,
    defaultValue,
    i18n,
    appliesTo,
    onAppliesToChange,
  }) {
    return (
      <Section>
        {/* [START discount-ui-extension.hidden-box] */}
        <Box display="none">
          <TextField
            value={value.map(({ id }) => id).join(",")}
            label=""
            name="collectionsIds"
            defaultValue={defaultValue.map(({ id }) => id).join(",")}
          />
        </Box>
        {/* [END discount-ui-extension.hidden-box] */}
        <BlockStack gap="base">
          <InlineStack blockAlignment="end" gap="base">
            <Select
              label={i18n.translate("collections.appliesTo")}
              name="appliesTo"
              value={appliesTo}
              onChange={onAppliesToChange}
              options={[
                {
                  label: i18n.translate("collections.allProducts"),
                  value: "all",
                },
                {
                  label: i18n.translate("collections.collections"),
                  value: "collections",
                },
              ]}
            />

            {appliesTo === "all" ? null : (
              <Box inlineSize={180}>
                <Button onClick={onClickAdd}>
                  {i18n.translate("collections.buttonLabel")}
                </Button>
              </Box>
            )}
          </InlineStack>
          <CollectionsSection collections={value} onClickRemove={onClickRemove} />
        </BlockStack>
      </Section>
    );
  }

  function CollectionsSection({ collections, onClickRemove }) {
    if (collections.length === 0) {
      return null;
    }

    return collections.map((collection) => (
      <BlockStack gap="base" key={collection.id}>
        <InlineStack blockAlignment="center" inlineAlignment="space-between">
          <Link
            href={`shopify://admin/collections/${collection.id.split("/").pop()}`}
            tone="inherit"
            target="_blank"
          >
            {collection.title}
          </Link>
          <Button variant="tertiary" onClick={() => onClickRemove(collection.id)}>
            <Icon name="CircleCancelMajor" />
          </Button>
        </InlineStack>
        <Divider />
      </BlockStack>
    ));
  }

  function App() {
    const {
      applyExtensionMetafieldChange,
      i18n,
      initialPercentages,
      onPercentageValueChange,
      percentages,
      initialTag,
      onTagValueChange,
      tag,
      resetForm,
      initialCollections,
      collections,
      appliesTo,
      onAppliesToChange,
      removeCollection,
      onSelectedCollections,
      loading,
    } = useExtensionData();

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

    return (
      <FunctionSettings onSave={applyExtensionMetafieldChange}>
        <Heading size={6}>{i18n.translate("title")}</Heading>
        <Form onReset={resetForm} onSubmit={applyExtensionMetafieldChange}>
          <Section>
            <BlockStack gap="base">
              <BlockStack gap="base">
                <PercentageField
                  value={String(percentages.product)}
                  defaultValue={String(initialPercentages.product)}
                  onChange={(value) => onPercentageValueChange("product", value)}
                  label={i18n.translate("percentage.Product")}
                  name="product"
                />
                <TagField
                  value={String(tag)}
                  defaultValue={String(initialTag)}
                  onChange={(value) => onTagValueChange("tag", value)}
                  label={i18n.translate("tag")}
                  name="tag"
                />

                
              </BlockStack>
              {collections.length === 0 ? <Divider /> : null}
             
              
            </BlockStack>
          </Section>
        </Form>
      </FunctionSettings>
    );
  }

  function useExtensionData() {
    const { applyMetafieldChange, i18n, data, resourcePicker, query } =
      useApi(TARGET);
    const metafieldConfig = useMemo(
      () =>
        parseMetafield(
          data?.metafields.find(
            (metafield) => metafield.key === "function-configuration"
          )?.value
        ),
      [data?.metafields]
    );
    const [percentages, setPercentages] = useState(metafieldConfig.percentages);
    const [tag, setTags] = useState(metafieldConfig.tag)
    const [initialCollections, setInitialCollections] = useState([]);
    const [collections, setCollections] = useState([]);
    const [appliesTo, setAppliesTo] = useState("all");
    const [loading, setLoading] = useState(false);

    useEffect(() => {
      const fetchCollections = async () => {
        setLoading(true);
        const selectedCollections = await getCollections(
          metafieldConfig.collectionIds,
          query
        );
        setInitialCollections(selectedCollections);
        setCollections(selectedCollections);
        setLoading(false);
        setAppliesTo(selectedCollections.length > 0 ? "collections" : "all");
      };
      fetchCollections();
    }, [metafieldConfig.collectionIds, query]);

    const onPercentageValueChange = async (type, value) => {
      setPercentages((prev) => ({
        ...prev,
        [type]: Number(value),
      }));
    };

  const onTagValueChange = async (type, value) => {
      setTags((prev) => ({
        ...prev,
        [type]: String(value),
      }));
    }; 

   /* const onTagValueChange = async (type, value) => {
      setTag({
        [type]: String(value),
      });
    }; */

    const onAppliesToChange = (value) => {
      setAppliesTo(value);
      if (value === "all") {
        setCollections([]);
      }
    };

    async function applyExtensionMetafieldChange() {
      await applyMetafieldChange({
        type: "updateMetafield",
        namespace: "$app:example-discounts--ui-extension",
        key: "function-configuration",
        value: JSON.stringify({
          cartLinePercentage: percentages.product,
          cartLineTag: tag,
          orderPercentage: percentages.order,
          deliveryPercentage: percentages.shipping,
          collectionIds: collections.map(({ id }) => id),
        }),
        valueType: "json",
      });
      setInitialCollections(collections);
    }

    const resetForm = () => {
      setPercentages(metafieldConfig.percentages);
      setTags(metafieldConfig.tag);
      setCollections(initialCollections);
      setAppliesTo(initialCollections.length > 0 ? "collections" : "all");
    };

    const onSelectedCollections = async () => {
      const selection = await resourcePicker({
        type: "collection",
        selectionIds: collections.map(({ id }) => ({ id })),
        action: "select",
        filter: {
          archived: true,
          variants: true,
        },
      });
      setCollections(selection ?? []);
    };

    const removeCollection = (id) => {
      setCollections((prev) => prev.filter((collection) => collection.id !== id));
    };

    return {
      applyExtensionMetafieldChange,
      i18n,
      initialPercentages: metafieldConfig.percentages,
      initialTag: metafieldConfig.tag,
      onPercentageValueChange,
      percentages,
      onTagValueChange,
      tag,
      resetForm,
      collections,
      initialCollections,
      removeCollection,
      onSelectedCollections,
      loading,
      appliesTo,
      onAppliesToChange,
    };
  }

  const METAFIELD_NAMESPACE = "$app:example-discounts--ui-extension";
  const METAFIELD_KEY = "function-configuration";

  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;
  }

  function parseMetafield(value) {
    try {
      const parsed = JSON.parse(value || "{}");
      return {
        percentages: {
          product: Number(parsed.cartLinePercentage ?? 0),
          order: Number(parsed.orderPercentage ?? 0),
          shipping: Number(parsed.deliveryPercentage ?? 0),
        },
        tag: String(parsed.cartLineTag.tag ?? ""),
        
        collectionIds: parsed.collectionIds ?? [],
      };
    } catch {
      return {
        percentages: { product: 0, order: 0, shipping: 0 },
        collectionIds: [],
        tag: "",
      };
    }
  }

  async function getCollections(collectionGids, adminApiQuery) {
    const query = `#graphql
      query GetCollections($ids: [ID!]!) {
        collections: nodes(ids: $ids) {
          ... on Collection {
            id
            title
          }
        }
      }
    `;
    const result = await adminApiQuery(query, {
      variables: { ids: collectionGids },
    });
    return result?.data?.collections ?? [];
  }

From my log files, the metafield configuration is:

"discountNode": {
    "metafield": {
      "value": "{\"cartLinePercentage\":25,\"cartLineTag\":{\"tag\":\"2FOR25\"},\"orderPercentage\":0,\"deliveryPercentage\":0,\"collectionIds\":[]}"
    }
  }

So there seems to be no way at all using the supplied template to FORCE, the metafields to be “flat” other than the supplied percentages and collectionIds. assume there is some library magic behind the curtains, is there an alternative template somewhere or resource where I can build my own UI?

I think the above function needs to modify the value of “tag” but I am unable to figure out why its setting it to a nested instead of flat value

Thanks.

Hi - I believe you’ll need to:

  1. Change the state to be a string, not an object.
  2. Change the onTagValueChange to set a string.
  3. Change parseMetafield to expect a string.
  4. Change the save logic to write a string.

If you’re using an IDE that can interact with an MCP, you could try troubleshooting with the Shopify Dev MCP, which has support for working with Functions and analysing your code in the context of your full project.

Hi Liam –

I ended up making a minor change to the following onTagValueChange:

const onTagValueChange = (type, value) => {
      setTag(String(value));
    };

The code that failed:

/* const onTagValueChange = async (type, value) => {
      setTags((prev) => ({
        ...prev,
        [type]: String(value),
      }));
    };  */

 /*   const onTagValueChange = async (_type, value) => {
      setTag(String(value));
    }; */

  

 /*   const onTagValueChange = async (type, value) => {
      setTag({
        [type]: String(value),
      });
    }; */

Was any of those. Solution is that onTagValueChange or any setter like that cannot be async, and cannot have the type included. You have to pass it otherwise the function uses the type as the value, just don’t use it and coerce to a string. Now sadly my function is no longer working, but I should be able to debug that issue.

HEre is my output from both the logs:

"discountNode": {
    "metafield": {
      "value": "{\"cartLinePercentage\":25,\"cartLineTag\":\"2FOR25\",\"orderPercentage\":0,\"deliveryPercentage\":0,\"collectionIds\":[]}"
    }
  }
}

And from my function:

configuration = {"cartLinePercentage":25,"cartLineTag":"2FOR25","orderPercentage":0,"deliveryPercentage":0,"collectionIds":[]}

Thanks for your help, I should be able to finish this. With staff permission when I get this finished I would like to publish a link to a git repo where people can use the finished project. The point behind the discount function is that we can have lots and lots of different priced products and reduce them to a price 2 for XX, in even amounts and call out to the customer to add one more if they have odd numbers of eligble items, and we use tags not collections as the tag utilities available on Shopify can do all sorts of tagging based on inventory age, price, past sales, etc. So tags are very useful, more so than collections for us.

Again, thanks.

Hi @Floyd_Wallace

Great to hear you’re making progress on this - please update this post once you’re finished. I’m sure that sharing an open repo with this function configuration will be hugely valuable for other devs in this community, so thanks for considering this. Best of luck!!

Liam – I have a public repo at GitHub - FLWallace105/public-2-xx-ui: Repo for how to create a 2 for XX shopify function product discount with Admin UI for configuration, can you see if you can approve this as a how-to; I have this code working in both the staging and production stores and have created the README.md to cover any gotchas and such like as I encountered. Thanks to your help everything works now.