typescriptgraphqlkeystonejs

Setting a dynamic default value for a field in Keystone (for a url slug)


Im totally new to both GraphQL and the concept of headless CMS having only ever built my own REST API's from scratch.

I've built out a basic API using Keystone v6 and lets say I have a Schema for products something like:

export const Item = list({
  fields: {
    name: text({ validation: { isRequired: true } }),
    description: text(),
    photos: relationship({
      ref: 'ProductImage.listing',
      many: true,
    }),
    category: relationship({
      ref: 'Category.listings',
      many: false,
    }),
    dateCreated: timestamp({
      defaultValue: { kind: 'now' },
    }),
    brand: text(),
    model: text(),
    size: text(),
    slug: text(),
  },
});

Cool, works.... Now say I want slug to be auto populated when a product is created with the something like ' "this-item-name" + "-" + "id" ' I cant figure out a/ how to do this or b/ even from a convention standpoint where to.

My assumption would be that i would use the defaultValue attribute of the text field? or would this be something to do with a mutation (a concept i'm not wrapping my head around very well yet)... and if so I cant figure out where in a boilerplate Keystone app I would even write a mutation.

Any pointers much appreciated.


Solution

  • The defaultValue option won't help you as Keystone 6 only supports static defaults and here we want to default a value dynamically.

    @Daniel's suggestion of a virtual field half works – it's a good way to derive one value from another – but virtual fields run when when the value is read from the DB. As such, you can't filter by a virtual field so, if you used one here, you'd never be able to find an item based on it's slug (which is one of the main things you're going to want to do).

    What you need here is a hook.

    Hooks are a Keystone feature that give you a way to run code before and after operations are performed on the GraphQL API. They let you attach functions at different points in the mutation lifecycle (eg, beforeOperation, afterOperation, etc.), causing them to run at different times.

    When invoked, the hook function is passed information about where and why it's being run (such the listKey or fieldKey and the operation being performed), along with the incoming data, existing data and Keystone context object, allowing you to query the API within a hook.

    You can also attach hooks at two different levels: the list level, where they'll have access to an entire operation, or the field level, where they can only effect the behaviour of a single field.

    It sounds a little complicated but the Hooks Guide and the Hooks API pages on the Keystone docs site should have you covered.

    In concrete terms, you probably want something like this:

    // A simple function to derive a url-safe value from a string input
    // We'll use this from within out hook
    function buildSlug(input) {
      return input
        .trim()
        .toLowerCase()
        .replace(/[^\w ]+/g, '')
        .replace(/ +/g, '-');
    }
    
    // Define the list schema
    export const Item = list({
      fields: {
        name: text({ validation: { isRequired: true } }),
        // ... all the other fields
    
        // Declare the `slug` as a normal text field
        slug: text({
    
          // Being a slug, it should be indexed for lookups and unique
          isIndexed: 'unique',
    
          // Define the hook function itself and attach it to the resolveInput
          // step of the mutation lifecycle
          hooks: {
            resolveInput: ({ operation, resolvedData, inputData }) => {
    
              // Lets only default the slug value on create and only if 
              // it isn't supplied by the caller.
              // We probably don't want slugs to change automatically if an 
              // item is renamed.
              if (operation === 'create' && !inputData.slug) {
                return buildSlug(inputData.name);
              }
    
              // Since this hook is a the field level we only return the 
              // value for this field, not the whole item
              return resolvedData.slug;
            },
          },
        }),
      },
    });
    

    You can see we've attached our hook at the resolveInput step of the mutation lifecycle. This is a good place to modify incoming values before they're saved:

    The resolveInput function is used to modify or augment the data values passed in to a create or update operation. — Hooks API docs

    Note we've used isIndexed: 'unique' to enforce uniqueness at the database level. As written, this will cause create operations to error if they would otherwise result in duplicate values. All hooks are passed the Keystone context object and can be async so you could, if you wanted, ensure the generated value was unique by checking the database before returning.

    Here, We've put our hook at the field level. We could also have written it at the list level but we're only modifying a single value so this is a little clearer to read.