Designing a Flexible UI Architecture
with special guests React and GraphQL

@kamranayub

background by saragnzalez

To design a flexible UI:

1. Find your common UI Patterns

2. formalize them into a schema

3. drive your ui from it

A "Schema-driven" UI

business RULES

handles

RENDERING hints

VALIDATION hints

Semantics

A "Schema-driven" UI

provides a declarative way to render your UI that separates business logic from rendering logic

Item data

Item data is complex

Managing item data

Challenges

complex ui state

Dynamic business rules

two different editing modes

scale to manage 200+ items at once

Horst Rittel and Melvin Webber defined a "wicked" problem as one that could be clearly defined only by solving it, or by solving part of it. This paradox implies, essentially, that you have to "solve" the problem once in order to clearly define it and then solve it again to create a solution that works.

 

Code Complete, Steve McConnell

Find Common UI Patterns

Find common UI patterns

Find common UI patterns

[
  {},
  {},
  {}
]

Find common UI patterns

[
  { 
    id: "product_title", 
    display_name: "Product Title" 
  },
  {
    id: "street_date", 
    display_name: "Street Date" 
  },
  { 
    id: "genres", 
    display_name: "Genres" 
  }
]

Find common UI patterns

[
  { 
    id: "product_title", 
    display_name: "Product Title",
    component_type: "single_line_text"
  },
  {
    id: "street_date", 
    display_name: "Street Date",
    component_type: "date"
  },
  { 
    id: "genres", 
    display_name: "Genres",
    component_type: "multi_select"
  }
]

Find common UI patterns

[
  { 
    id: "product_title", 
    display_name: "Product Title",
    component_type: "single_line_text",
    min_length: 0,
    max_length: 1337
  },
  {
    id: "street_date", 
    display_name: "Street Date",
    component_type: "date",
    min_date: 1569861733,
    max_date: null
  },
  { 
    id: "genres", 
    display_name: "Genres",
    component_type: "multi_select",
    min_values: 1,
    max_values: 3
  }
]

Find common UI patterns

{ 
  id: "product_title", 
  display_name: "Product Title",
  component_type: "single_line_text",
  notes: [
    "Not editable by VENDOR role"
  ]
}
{
  id: "street_date", 
  display_name: "Street Date",
  component_type: "date",
  notes: [
    "Must be today or future"
  ]
}
  • PO and UX could update
  • Structured metadata
  • Included human readable notes like business rules or "gotchas"

Find common UI patterns

{ 
  id: "product_title", 
  display_name: "Product Title",
  component_type: "single_line_text",
  min_length: 0,
  max_length: 1337,
  notes: [
    "Not editable by VENDOR role"
  ]
}
{ 
  id: "product_title", 
  display_name: "Product Title",
  component_type: "single_line_text",
  min_length: 0,
  max_length: 1337,
  read_only: false,
  notes: [
    "For VENDOR role, READ_ONLY is TRUE"
  ]
}

rethinking item data

rethinking item data

item

product_title

street_date

rethinking item data

{
  "product_title": "Pokemon Sword",
  "street_date": "2019-11-15T00:00:00Z"
}

This is a "consumer" way of thinking

How do we describe data?

Formalize into a schema

Data Schema

product_title

rules

display

values

street_date

rules

display

values

Data schema

{
  "product_title": {
    "display_name": "Product Title",
    "description": "A product's title",
    "default_rules": {
      "required": true,
      "min_length": 1,
      "max_length": 1337,
      "read_only": false
    },
    "display": {
      "component_type": "single_line_text",
      "help_text": null      
    },
    "item_values": [
      {
        "id": "item1",
        "value_raw": "Pokemon Sword", 
        "value_human": "Pokemon Sword" 
      },
      {
        "id": "item2",
        "value_raw": "Pokemon Sword (Digital Edition)", 
        "value_human": "Pokemon Sword (Digital Edition)",
        "override_rules": {
          "required": false,
          "read_only": true
        }
      }
    ]
  },
  "street_date": {
    "display_name": "Street Date",
    "description": "The date this item will be available for purchase",
    "default_rules": {
      "required": true,
      "min_date": 1555818103157,
      "max_date": null,
    },
    "display": {
      "component_type": "date",
      "help_text": "Must be later than today's date"
    },
    "item_values": [
      {
        "id": "item1",
        "value_raw": "2019-11-15T00:00:00Z", 
        "value_human": "Nov 15, 2019",
        "override_rules": {
          "min_date": null
        }
      }
    ]
  }
}

Drive ui from the schema

primary

data

external data

caching layer

GraphQL Backend

schema

engine

data

resolution

React Frontend

state

management

client rule engine

High-level architecture

Why graphql?

GraphQL is a specification

which provides

predictability

consistency

safety

Why graphql?

type ItemAttribute {
  id: String!
  display: AttributeDisplay
}

type AttributeDisplay {
  component_type: ComponentType!
}

enum ComponentType {
  single_line_text
  multi_line_text
  number
  date
  datetime
  multi_option
}

Static typing

Powerful query capabilities

query data($attribute_ids: [String!]!, $item_ids: [String!]!, hydrate: Boolean!) {
  attributes(ids: $attribute_ids) {
    id
    title: description
    default_rules {
      max_length
    }
    values(items: $item_ids) {
       raw_value
       human_value @include(if: $hydrate)
       override_rules {
         max_length
       }
    }
  }

  item_metadata(ids: $item_ids) {
    last_updated
  }
}

Why graphql?

type AttributeRules {
  required: Boolean
  read_only: Boolean
  max_length: Int
  max_chars: Int @deprecated(reason: "Use `max_length` instead")
}

One version, field-level reporting

subscription item_updates(ids: ["item1"]) {
  item_id
  values {
    raw_value
    human_value
  }
}

Subscriptions

Why graphql?

Plus

Caching infrastructure

Field-level timing and analytics

Custom directives

Extensible architecture

But seriously,

you could totally use REST too

Data resolution

query {
  itemData(attribute_ids: ["genres"]) {
    attributes {
      id
      default_rules {
        required
        read_only
      }
      display {
        component_type
      }
      values(item_ids: ["item1", "item2"]) {
        item_id
        raw_value
        human_value
        override_rules {
          required
        }
      }
    }
  }
}

Load raw data

Load external data

Map the attributes

Data resolution

Mapping the attributes

genres
export default {
  id: "genres",
  
  async fetchData(context) {
    const { itemIds, services } = context;
    
    // fetch from db
    const itemData = await services.db.getItems(itemIds);
    
    return itemData.map(data => ({
      item_id: data.id,
      raw_value: data.genres.map(genre => genre.genre_id);
    }))
  }
}

Data resolution

Fetching raw and human data

genres

Database

[1, 3]

External

APIs

["Adventure", "Role-Playing Game"]

Data resolution

Fetching raw and human data

genres
title

Database

per-request cache

Data resolution

genres
platforms
brands
publisher
...

Fetching raw and human data

Caching layer

Service 1

Service 2

Schema Engine

{
  id: "genres",
  default_rules: {
    required: true,
    read_only: false
  },
  display: {
    component_type: "multi_select"
  },
  values: [
    {
      item_id: "item1",
      raw_value: 2,
      human_value: "Adventure",
      override_rules: null
    }
  ]
}

Merge in values

Run rules

Tweak schema

Finalize

Base schema

Schema engine

Base schema

{
  "product_title": {
    "display_name": "Product Title",
    "description": "A product's title",
    "default_rules": {
      "required": true,
      "min_length": 1,
      "max_length": 1337,
      "read_only": false
    },
    "display": {
      "component_type": "single_line_text",
      "help_text": null      
    }
  },
  "street_date": {
    "display_name": "Street Date",
    "description": "The date this item will be available for purchase",
    "default_rules": {
      "required": true,
      "min_date": 1555818103157,
      "max_date": null,
    },
    "display": {
      "component_type": "date",
      "help_text": "Must be later than today's date"
    }
  }
}

Schema Engine

{
  id: "genres",
  default_rules: {
    required: true,
    read_only: false
  },
  display: {
    component_type: "multi_select"
  },
  values: [
    {
      item_id: "item1",
      raw_value: 2,
      human_value: "Adventure",
      override_rules: null
    }
  ]
}
{
  id: "genres",
  default_rules: {
    required: true,
    read_only: false
  },
  display: {
    component_type: "multi_select"
  },
  values: [
    {
      item_id: "item1",
      raw_value: 2,
      human_value: "Adventure",
      override_rules: {
        required: false,
        read_only: true
      }
    }
  ]
}

Schema Engine

Business rules run on the server and are represented in simple terms to the client

primary

data

external data

caching layer

GraphQL Backend

schema

engine

data

resolution

React Frontend

state

management

client rule engine

High-level architecture

React UI

Schema rendering

undo / rollback values

relationship semantics

multi-item handling

Schema rendering

{
  "attributes": [
    {
      "id": "product_title",
      "display": {
        "component_type": "single_line_text"
      }
    }
  ]
}
import * as Components from './componentTypes';

function renderField(componentType, props) {
  if (Components[componentType]) {
    return React.createElement(
      Components[componentType], 
      props
    );
  }
  
  throw new Error(`Unknown component type: ${componentType}`)
}

SCHEMA rendering

{
  "attributes": [
    {
      "id": "product_title",
      "default_rules": {
         "read_only": true
      },
      "display": {
        "component_type": "single_line_text"       
      },
      "item_values": [
        {
          "id": 1,
          "raw_value": "Pokemon Sword",
          "human_value": "Pokemon Sword"
        }
      ]
    }
  ]
}
function SingleLineText(props) {
  const { read_only, raw_value, human_value } = props;
  
  const [value, setValue] = React.useState(raw_value);
  const handleChange = React.useCallback(e => {
    setValue(e.target.value);
  });
  
  if (read_only) {
    return (<div>{human_value}</div>);
  }
  
  return (
    <input type="text" value={value} onChange={handleChange} />
  )
}

SCHEMA rendering

{
  "attributes": [
    {
      "id": "product_title",
      "default_rules": {
         "read_only": true,
         "max_length": 50
      },
      "display": {
        "component_type": "single_line_text"
      }
    }
  ]
}

SCHEMA rendering

{
  "attributes": [
    {
      "id": "genres",
      "display": {
        "component_type": "multi_select"
      }
    }
  ]
}

Schema rendering

query {
  lookup(attribute_id: "genres") {
    page_size
    total
    options(filter: "act") {
      raw_value
      human_value
    }
  }
}

Undo / Rollback

review before save

Quick data entry

Batched save

Undo / Rollback

Action

Time

Action

Action

Change

Change

Undo / Rollback

{
  "item1": {
    "product_title": [
      {
        "raw_value": "version 3", "human_value": "version 3"
      },
      {
        "raw_value": "version 2", "human_value": "version 2"
      }
    ]
  }
}

Relationship semantics

Relationship semantics

Taxonomy

Attribute

Group

"Editor"

e.g. product_title

e.g. dimensions (WxHxD)

e.g. complex attributes

Relationship semantics

prevent updates

Relationship semantics

Hierarchical / Source

Relationship semantics

Conditional

Graph theory and math comes in handy!

Relationship semantics

Schema representation

{
  "id": "primary_genre",
  "relationships": [
    { "type": "hierarchy", "related_id": "genres" }
  ]
}
{
  "id": "genres",
  "relationships": [
    { "type": "blocking", "related_id": "product_title" }
  ]
}
{
  "id": "number_of_players",
  "relationships": [
    { "type": "conditional", "related_id": "genres", "when": [1], "match": "some" }
  ]
}

multi-item handling

mixed edits

variedness

payload size

multi-item handling

mixed edits

multi-item handling

Variedness

multi-item handling

Payload size

What about saving data?

{
  product_title: [
    { item_ids: ["item1", "item2"], value: "Pokemon Sword" },
    { item_ids: ["item3"], value: "Pokemon Sword (Digital Edition)" }
  ],
  street_date: [
    { item_ids: ["item1", "item2", "item3"], value: "2019-11-15T00:00:00Z" }
  ]
}

Send

Compare

Validate

Persist

Publish

The save flow reuses much of the same schema infrastructure

Async and optimistic

To design a flexible UI:

1. Find your common UI Patterns

2. formalize them into a schema

3. drive your ui from it

Where can you start?

First, decide whether this is something you'll find value from. Do you have dozens or hundreds of data points to support?

If it doesn't, then roll your own schema

Questions?

Things I didn't talk about:

State management

Testing

Universal rule engine

Universal rule engine

  • Runs on both client and server
  • Allows you to share logic
  • Makes the feedback loop faster
  • We didn't do this but I wish we did

State management

  • Redux
  • Handles merging of server schema and client journal
  • Allows us to time-travel debug in user sessions

Testing

  • 1000+ unit tests
  • 100+ functional tests
  • Using Jest and Cypress
  • Recommend using Storybook

@kamranayub

Designing a Flexible UI Architecture with React and GraphQL (MDC 2019)

By Kamran Ayub

Designing a Flexible UI Architecture with React and GraphQL (MDC 2019)

MDC 2019 presentation on flexible UI architecture with React and GraphQL. Recording available soon.

  • 759