Update editor state of parent from child component for dynamic GraphQL query generation

I am currently working on a custom component that is based on GraphQL. The idea is that I fetch the introspection model once when placing the data fetching component in the plasmic editor. I then use context to pass down the Introspection model so children can select specific slices of data from that model. My goal is to be able to update the GraphQL query that lives in the parent component to include newly selected fields that were selected in a child component by the plasmic user.

The end goal is to construct the GraphQL query in the parent data fetching component dynamically based on child component data selection so during app runtime this query represents the minimum data that must be fetched to provide content for the child components.

I cannot seem to find a way to pass the selected data information from my children back to the parent component so I can update the GraphQL query to include the newly selected data. I tried passing an update function with my custom data context that calls setControlContextData on the parent component in order to update the GraphQL query, but this does not seem to work at all.

I think what you described works. Maybe you can post your code here, including how you set up the custom data context with setControlContxetData.

It does work if I use a dedicated prop with type “custom” for the GraphQL query because the custom contextData is available there. My initial attempt was to try and overwrite the value from a

type: ‘code’, lang: ‘graphql’

property, but that did not work. Currently I am using a custom control that renders its own GraphiQL-editor and that can get updated with children calling the setControlContextData function.

Right now it is possible to send selected properties back to the parent, but once the child component is removed in the editor, there is no way for the parent to be informed about that. This means the GraphQL query cannot be cleaned up or am I missing some interface that allows the parent to be informed about child components being removed in the editor?

Hmm it’s kinda hard to understand just with the text description. Maybe you can share some code or a link to your project?

My testing setup includes three components at the moment:

  • ContentPostBuilder
  • ContentPostBuilderContent
  • ContentPostBuilderContentValue

The component tree is scoped to a specific GraphQL type: ContentPost. Besides of root-level properties, it has a property called “content” which is the relevant property for this issue. The value is an array that can have multiple types of elements as values in any order, hence the query is augmented with __typename before being sent. Think of the element values of the content array as a tagged union, where __typename is the discriminator.

ContentPostBuilder is responsible for storing the GraphQL query and to provide the ContentPostContext with the retrieved data and the introspection model. The addGraphQlProperty function on the context is supposed to be used from child components to register a GraphQL-Path that should trigger an update in ContentPostBuilder to include that property path in the GraphQL-Query. This is the feature that I cannot seem to get working.

ContentPostBuilderContent uses the __typename from the actual data to decide if it renders or not. For debugging purposes it outputs “__typename mismatch” if the user-selected __typename does not match the data typename. In the expected end result, this just returns null so it does not render. Furthermore, the ContentPostBuilderContext passes the current schema, gql schema path and data slice from the GQL query to its children.

Finally, the ContentPostBuilderContentValue consumes both contexts to allow the user to select properties from the currently selected __typename in the content. Whenever the user changes selection, an update should be made to the GraphQL query in the ContentPostBuilder. Thats why it calls builderContext.addGraphQlPropertyPath(gqlPath). This does not work however, because I cannot manipulate properties in ContentPostBuilder with setControlContextData. This makes sense and documentation states that only choice types will trigger once the function is called.

Some example content value:

[
{"__typename":"header", "title":"some title", "subtitle": "a minor subtitle"},
{"__typename":"TextBlock", "textcontent":"some longer textcontent"
]

This means that the user can use a ContentPostBuilderContent element and choose between “header” and “textblock” with the dropdown. If the user selected “header” and if the user adds a ContentPostBuilderContentValue as a child to ContentPostBuilderContent, the user can select “title” or “subtitle” and the GraphQL-Query in the ContentPostBuilder should update with the path given via the callback in the context.

All ContentPostBuilder children are single instances of a conditional renderer for a specific __typename of the tagged union, thats why we include all children for each content element in ContentPostBuilder.

I also tried to iterate all children and collect the used GraphQL-properties via studioops, but that did not work as expected either. Any guidance would be appreciated.


export type ContentPostBuilderProps = {
  children: ReactElement;
  introspectionModel?: any;
  startIndex: number;
  page: number;
  pageSize: number;
  query: undefined | { query: string };
  setControlContextData?: (ctxData: { query: string }) => void;
};

export type ContentPostContext = {
  data: Record<string, any> | null;
  introspectionModel: GraphQLSchema | null;
  addGraphQlPropertyPath: (valuePath: string) => void;
};

export const ContentPostContext = React.createContext<ContentPostContext>({
  data: null,
  introspectionModel: null,
  addGraphQlPropertyPath: _ => _,
});

export default function ContentPostBuilder({
  children,
  page,
  query,
  pageSize,
  introspectionModel,
  setControlContextData,
}: ContentPostBuilderProps) {
  const { client, stage } = useGraphQlContext();
  const inEditor = !!usePlasmicCanvasContext();

  if (!query) {
    return <p>Configure a GraphQl query.</p>;
  }
  if (!introspectionModel) {
    return <>Please use introspection once.</>;
  }
  const augmentedQuery = augmentWithTypename(query.query);
  const { data } = usePlasmicQueryData(augmentedQuery, async () => {
    const data = await client.request(augmentedQuery, {
      skip: pageSize * (page - 1),
      first: pageSize,
    });
    const keys = Object.keys(data);
    if (keys.length > 1) {
      throw new Error('Expected only one result type in GraphQL Query.');
    }
    return data;
  });
  if (!data) {
    return <>no data</>;
  }
  let contentPost = data.contentPost as any;
  const schema = buildClientSchema(introspectionModel);
  return (
    <div>
      {contentPost.content.map(contentEntry => {
        return (
          <ContentPostContext.Provider
            value={{
              data: contentEntry,
              introspectionModel: schema,
              addGraphQlPropertyPath: newValue => {
                console.log('updateGraphQLQuery', newValue);
                setControlContextData!({
                  query: 'test',
                });
              },
            }}
          >
            {children}
          </ContentPostContext.Provider>
        );
      })}
      {query.query}
    </div>
  );
}

export const contentPostBuilderComponentMeta: CodeComponentMeta<ContentPostBuilderProps> =
  {
    name: 'company-content-post-builder',
    displayName: 'company Content Post Builder',
    importPath: '@company/plasmic-components',
    importName: 'companyContentPostBuilder',
    props: {
      children: {
        type: 'slot',
        defaultValue: [],
      },
      query: {
        // this seems to not be updateable by setControlContextData calls
        type: 'code',
        lang: 'graphql',
        readOnly: false,
        endpoint: (props, ctx) =>
          '...',
        headers: (props, ctx) => ({
//...
        }),
      },
      page: {
        type: 'number',
        defaultValue: 1,
      },
      pageSize: {
        type: 'number',
        defaultValue: 10,
      },
      introspectionModel: {
        type: 'object',
        description:
          'Stores the introspection model for Hygraph content elements.',
        readOnly: true,
      },
      graphQlDataPaths: {
        type: 'array',
        defaultValue: [],
      },
    },
    actions: [
      {
        type: 'button-action',
        label: 'Introspect',
        onClick: async ({ studioOps }) => {
          studioOps.updateProps({
            introspectionModel: await getFullIntrospection(),
          });
        },
      },
      {
        type: 'button-action',
        label: 'Reset Query',
        onClick: async ({ studioOps, componentProps, contextData }) => {
// This was an attempt to iterate all children and extract used graphql-paths from them
          const queue = [];
          let gqlPaths = [];
          Children.forEach(componentProps.children, c => queue.push(c));
          let current: any = null;
          while ((current = queue.shift())) {
            if (current.props.children) {
              if (current.props.children) {
                Children.forEach(current.props.children, c => queue.push(c));
              }

              if (current.props.gqlPathValue) {
                gqlPaths.push(current.props.gqlPathValue);
              }
            }
          }
          console.log(
            gqlPaths,
            componentProps,
            contextData,
            componentProps.children
          );
        },
      },
    ],
  };

export const registerContentPostBuilder = buildRegisterComponentFn(
  ContentPostBuilder,
  contentPostBuilderComponentMeta
);

export const ContentPostBuilderContentContex = React.createContext<{
  schema: GraphQLNamedType | null;
  selectedComponent: string;
  dataSlice: null;
  currentGqlPath: string;
}>({
  schema: null,
  selectedComponent: '',
  dataSlice: null,
  currentGqlPath: '',
});

export type ContentBuilderElementProps = {
  children: ReactElement;
  component?: string;
  renderElement: boolean;
  setControlContextData?: (ctxData: { allComponents: string[] }) => void;
};

export default function ContentPostBuilderContent({
  children,
  component,
  setControlContextData,
}: ContentBuilderElementProps) {
  const inEditor = !!usePlasmicCanvasContext();
  const context = ensure(useContext(ContentPostContext));
  const gqlTypeDefinition = context.introspectionModel?.getType(
    'ContentPostcontentUnion'
  );
  if (isUnionType(gqlTypeDefinition)) {
    setControlContextData?.({
      allComponents: gqlTypeDefinition.getTypes().map(t => t.name),
    });
  }
  if (!component) {
    if (inEditor) {
      return (
        <p>
          Please select a the Content Type to render for the Content Builder
          Element.
        </p>
      );
    }
  }
  if (!gqlTypeDefinition) {
    return <p>Could not resolve ContentPostcontentUnion</p>;
  }
  if (context.data!.__typename !== component) {
    return <p>Typename mismatch</p>;
  }
  return (
    <ContentPostBuilderContentContex.Provider
      value={{
        schema: gqlTypeDefinition,
        dataSlice: context.data,
        selectedComponent: component || '',
        currentGqlPath: 'content.#' + component,
      }}
    >
      <h1>{children}</h1>
    </ContentPostBuilderContentContex.Provider>
  );
}

export const contentBuilderElementComponentMeta: CodeComponentMeta<ContentBuilderElementProps> =
  {
    name: 'company-content-post-element',
    displayName: 'company Content Builder Content',
    importPath: '@company/plasmic-components',
    importName: 'ContentBuilderElement',
    props: {
      children: 'slot',
      component: {
        type: 'choice',
        options: (props, ctx) => {
          return ctx?.allComponents || [];
        },
      },
    },
  };

export const registerContentPostBuilderContent = buildRegisterComponentFn(
  ContentPostBuilderContent,
  contentBuilderElementComponentMeta
);

export type ContentBuilderElementProps = {
  children: ReactElement;
  value: string;
  setControlContextData?: (ctxData: {
    value: string[];
    gqlPathValue: string;
  }) => void;
};

export default function ContentPostBuilderContentValue({
  value,
  setControlContextData,
}: ContentBuilderElementProps) {
  const inEditor = !!usePlasmicCanvasContext();
  const context = ensure(useContext(ContentPostBuilderContentContex));
  const builderContext = ensure(useContext(ContentPostContext));
  const currentGqlType = builderContext.introspectionModel?.getType(
    context.selectedComponent
  );
  if (isObjectType(currentGqlType)) {
    setControlContextData?.({ value: Object.keys(currentGqlType.getFields()) });
  }
  if (value !== '' && inEditor) {
    const gqlPath = context.currentGqlPath + '.' + value;
    builderContext.addGraphQlPropertyPath(gqlPath);
    setControlContextData?.({
      gqlPathValue: gqlPath,
      value: currentGqlType ? Object.keys(currentGqlType.getFields()) : [],
    });
  }
  return <>{context.dataSlice[value] || value + ' does not exist on data.'}</>;
}

export const contentBuilderElementComponentMeta: CodeComponentMeta<ContentBuilderElementProps> =
  {
    name: 'company-content-post-element-value',
    displayName: 'company Content Builder Content Value',
    importPath: '@company/plasmic-components',
    importName: 'ContentBuilderElementValue',
    props: {
      children: 'slot',
      renderElement: {
        type: 'boolean',
        description:
          'Renders this element even it is not used in the selected preview content post.',
        defaultValue: false,
      },
      value: {
        type: 'choice',
        options: (props, ctx) => {
          console.log('SET');
          return ctx?.value || [];
        },
      },
// this prop can also not be set via code
      gqlPathValue: {
        type: 'string',
      },
    },
  };

export const registerContentPostBuilderContentValue = buildRegisterComponentFn(
  ContentPostBuilderContentValue,
  contentBuilderElementComponentMeta
);