Introducing React ReExt – Sencha Ext JS Components in React! LEARN MORE

How to use GraphQL with Ext JS—A Tutorial

February 18, 2021 172 Views

TL;DR GraphQL schema and ExtJS data model work together very well. Apollo Client provides a core library that can be included in Javascript Web Framework projects to easily manage GraphQL requests, adding powerful features such as in-memory caching.

In this article, I describe a proxy implementation that completely wraps the GraphQL integration, generating requests from ExtJS models (with associations) and their values. The proxy can be found here, along with a working example. Are you prepared to learn about GraphQL using Ext JS? Let’s take a look at it!

What is GraphQL?

GraphQL is a great tool for developers to control the data they need from an API: by introducing schemas, it provides a standard structure for your APIs. It requests you to define object types and fields, unlike the REST APIs that are based on a style convention.

The GraphQL structured approach in remote communication allows you to use a lot of productivity tools both in server-side runtime and front-end applications. These include testing, auto-generated documentation, editing and multi-language SDKs.

From the client side, the main difference with REST is the ability to send queries to the server, specifying exactly what you need rather than relying on an “unpredictable” route implementation.

More about Ext JS

ExtJS is a framework for Web and mobile Single Page Applications and it is quite popular for development of reach-data interfaces. It is also a first-class citizen in the front-end technologies in terms of “productivity” and ” schema” and this is the reason why GraphQL is a promising tool for ExtJS data management.

At the time of writing, there is no built-in integration for GraphQL queries in the framework, but in this article we’ll  see how to integrate GraphQL in an ExtJS application in order to enable remote communication with a GraphQL back-end and benefit from of all the GraphQL features.

Note: an alternative approach can be found using ExtReact taking advantage of Apollo for React integration. Here is the tech talk repo.

GraphQL client library

When I first introducedGraphQL, I said that one of the advantages are the productivity tools. A production-ready JS library has to be there, and the solutions out there are certainly more than a client SDK. The two options are:

I chose Apollo Client because it provides a core library that is framework agnostic rather than Relay which is focused on React use-case. In addition, Apollo is a very popular platform for development of both GraphQL clients and server APIs.

GraphQL server

Since the implementation of GraphQL server is not in the scope of this how-to article, I  assume you have a working back-end, or you can start a new Apollo Server project from the official tutorial.

This is the example schema used for this article.

type Query {
        getUsers(
        limit: Int
        offset: Int
        orderBy: String
        filter: String
        ): Users
        user(id: Int!): User
}

type Mutation {
        createUser(createUserInput: CreateUserInput!): Int
        updateUser(
        id: Int!
        updateUserInput: UpdateUserInput!
        ): Int
       deleteUser(id: Int!): Int
}

type User {
      id: Int!
      username: String!
      firstName: String
      lastName: String
      role: String!
      email: String!
      areas: [Area!]
}

type Area {
     id: Int!
     name: String!
}

type Users {
     count: Int!
     users: [User!]!
}

input CreateUserInput {
      username: String!
      firstName: String!
      lastName: String!
      role: String!
      email: String!
      areas: [Int!]!
}

input UpdateUserInput {
     username: String
     firstName: String
     lastName: String
     role: String
     email: String
     areas: [Int!]
     password: String
}

ExtJS application setup

The test case analysed here is based on the ExtJS framework version 7.3.1 using the modern toolkit. The same code and principles can be applied to classic toolkit projects.

The following instructions are specifically for the new tooling based on NPM, but to obtain the same result using Sencha CMD you will need to download and include the two lib js files as external resources.

Generate the project.

ext-gen app -a -t moderndesktopminimal -n GraphQLApp

Then, from the project dir execute.

npm install --save @apollo/client
npm install --save graphql

Edit the index.js file.

require('graphql');
GraphQLApp.xApolloClient = require('@apollo/client/core');

Initialize the client

A single instance of Apollo client must be created during application launch where we can specify our preferred configuration.

A singleton class GraphQL can be useful to create the client ensuring that it will be accessible from other components.

Ext.define('GraphQLApp.GraphQL', {
	alternateClassName: ['GraphQL'],
	singleton: true,
 
	client: null,
 
	initClient(baseUrl) {
    	this.client = new GraphQLApp.xApolloClient.ApolloClient({
        	cache: new GraphQLApp.xApolloClient.InMemoryCache({}),
        	uri: baseUrl,
        	defaultOptions: {
            	query: {
                	fetchPolicy: 'network-only',
              	  errorPolicy: 'all',
            	},
            	mutate: {
                	errorPolicy: 'all',
            	},
        	},
    	});
	},
});

The init function can be called during the launch process (in Application.js).

launch()
{
	GraphQL.initClient('http://localhost:3000/graphql');
	Ext.Viewport.add([{ xtype: 'mainview' }]);
}

Caching options

One of the main benefits of Apollo is the outstanding client caching system provided with the library. The InMemoryCache class is provided by the package and enables your client to respond to future queries for the same data without sending unnecessary network requests.

Many options are available to configure Apollo caching, in many cases you will just need to select your preferred fetch policy (ex. cache-first or network-only). Here is the official documentation.

Send a GraphQL request

The client is now ready to be used in your controllers to query GraphQL data from the back-end. The response is automatically processed and response data, both from success and error, will be available as js objects.

GraphQL.client
.query({
	query: GraphQLApp.xApolloClient.gql\`
        	query GetUsers {
            	getUsers {
                	count
                	users {
                    	id
                    	username
                	}
            	}
        	}
    	\`
})
.then(result => console.log(result));

This query will retrieve just the total count of system users with the list of their ids and usernames, without loading additional fields and relations.

ExtJS data model with GraphQL schema

GraphQL queries rely on a schema that is shared between sender and receiver in order to allow the client to request exactly the data it needs.

ExtJS provides a data package to manage the structure and records of data in the application, and the UI components (grids, select fields, trees, forms, …etc) are able to visualize this data. For this reason, ExtJS applications should be able to communicate with GraphQL APIs representing the schema with their data models, with absolutely no modification on UI components and controllers.

To integrate GraphQL through the data package we will need to:

  • Define the GraphQL schema using Models, with fields and associations. There is no need for any customization.
  • Implement a custom Proxy. It should extend the ServerProxy and provide the logic of query generation. The inputs for query generation are the data models, pagination params, sort params and filter params.
  • Implement a custom Reader. It must be a very simple implementation because Apollo client already parses json responses, we just need to create new Models from Apollo results.

GraphQL proxy and reader implementation

The GraphQL proxy can extend the built-in Ext.data.proxy.Server class. That is the base class for Soap and Ajax proxies which are widely used in ExtJS applications. The GraphQL integration is based on ajax requests with JSON payloads, this is why most of the logic from ServerProxy is still valid for GraphQL. The custom implementation is limited to the requests (GraphQL queries) generation and parsing responses.

Here is the full implementation with details explained in the following sections.

Ext.define('GraphQLApp.proxy.GraphQLProxy', {
	extend: 'Ext.data.proxy.Server',
	alias: 'proxy.graphql',
 
	config: {
    	pageParam: '',
    	startParam: 'offset',
    	limitParam: 'limit',
    	sortParam: 'orderBy',
    	filterParam: 'filter',
    	query: {
        	list: undefined,
        	get: undefined
    	},
    	mutation: {
        	create: undefined,
        	update: undefined,
        	destroy: undefined,
    	},
    	readListTpl: [
        	'query {',
        	'{name}(',
        	'<tpl foreach="params" between=",">',
        	'{\$}:<tpl if="!Ext.isString(values)">{.}<tpl else>"{.}"</tpl>',
        	'</tpl>',
        	') {',
        	'{totalProperty},',
        	'{rootProperty} {',
        	'{fields}',
        	'}}}'
    	],
    	readSingleTpl: [
        	'query {',
        	'{name}(',
        	'<tpl foreach="params" between=",">',
        	'{\$}:<tpl if="!Ext.isString(values)">{.}<tpl else>"{.}"</tpl>',
        	'</tpl>',
        	') {',
        	'{fields}',
        	'}}'
    	],
    	saveTpl: [
        	'mutation {',
        	'{name} (',
        	'<tpl if="action == \\'update\\'">',
        	'{idParam}: {id},',
        	'</tpl>',
        	'{name}Input: {',
        	'{values}',
        	'})}',
    	],
    	deleteTpl: [
        	'mutation {',
        	'{name} (',
        	'{idParam}: {id}',
        	')}',
    	],
	},
 
	applyReadListTpl(tpl) {
    	return this.createTpl(tpl);
	},
 
	applyReadSingleTpl(tpl) {
    	return this.createTpl(tpl);
	},
 
	applySaveTpl(tpl) {
    	return this.createTpl(tpl);
	},
 
	applyDeleteTpl(tpl) {
    	return this.createTpl(tpl);
	},
 
	createTpl(tpl) {
    	if (tpl && !tpl.isTpl) {
        	tpl = new Ext.XTemplate(tpl);
    	}
    	return tpl;
	},
 
	encodeSorters(sorters, preventArray) {
    	const encoded = this.callParent([sorters, preventArray]);
    	// Escape double quotes to pass in GQL string
    	return encoded.replace(/"/g, '\\\\"');
	},
 
 
	encodeFilters(filters) {
    	const encoded = this.callParent([filters]);
    	// Escape double quotes to pass in GQL string
    	return encoded.replace(/"/g, '\\\\"');
	},
 
	doRequest(operation) {
    	const me = this,
        	action = operation.getAction(),
        	requestPromise = action === 'read' ? me.sendQuery(operation) : me.sendMutation(operation);
 
    	requestPromise
        	.then((result) => {
            	if (!me.destroying && !me.destroyed) {
                    me.processResponse(!result.errors, operation, null, Ext.merge(result, {
                    	status: result.errors ? 500 : 200
                	}));
            	}
        	})
        	.catch((error) => {
            	if (!me.destroying && !me.destroyed) {
                	me.processResponse(true, operation, null, {
                    	status: 500
                	});
            	}
        	});
	},
 
	sendQuery(operation) {
    	const me = this,
        	initialParams = Ext.apply({}, operation.getParams()),
        	params = Ext.applyIf(initialParams, me.getExtraParams() || {}),
        	operationId = operation.getId(),
        	idParam = me.getIdParam();
    	let query;
 
    	Ext.applyIf(params, me.getParams(operation));
 
    	if (operationId === undefined) {
        	// Load list
        	query = me.getReadListTpl().apply({
            	name: me.getQuery()['list'],
            	params,
            	fields: me.getFields(me.getModel()),
            	totalProperty: me.getReader().getTotalProperty(),
            	rootProperty: me.getReader().getRootProperty()
        	});
    	} else {
        	// Load recod by id
        	if (params[idParam] === undefined) {
            	params[idParam] = operationId;
        	}
        	query = me.getReadSingleTpl().apply({
            	name: me.getQuery()['get'],
            	params,
            	fields: me.getFields(me.getModel()),
            	recordId: operationId
        	});
    	}
 
    	return GraphQL.client
        	.query({
            	query: GraphQLApp.xApolloClient.gql(query)
        	});
	},
 
	sendMutation(operation) {
    	const me = this,
            action = operation.getAction(),
        	records = operation.getRecords();
    	let query;
 
    	if (action === 'destroy') {
        	// Delete record
        	query = me.getDeleteTpl().apply({
            	name: me.getMutation()[action],
            	idParam: me.getIdParam(),
            	id: records[0].getId(),
        	});
    	} else {
        	// Save record
        	query = me.getSaveTpl().apply({
            	name: me.getMutation()[action],
            	values: me.getValues(records[0]),
            	action,
            	idParam: me.getIdParam(),
            	id: records[0].getId(),
        	});
    	}
 
    	return GraphQL.client
        	.mutate({
            	mutation: GraphQLApp.xApolloClient.gql(query)
        	});
	},
 
	privates: {
    	getFields(model) {
        	const me = this;
        	const fields = model.prototype.fields;
        	return fields
            	.filter(field => !field.calculated)
            	.map(field => {
                	if (!field.reference) {
                    	return field.name;
                	} else {
                    	return \`\${field.name} {\${me.getFields(Ext.data.schema.Schema.lookupEntity(field.reference.type))}}\`;
                	}
            	})
            	.join(',');
    	},
 
    	getValues(record) {
        	const values = record.getData({ associated: true, changes: !this.getWriter().getWriteAllFields() });
        	const valuesArray = [];
        	delete values[this.getIdParam()];
 
        	Ext.Object.each(values, (key, value) => valuesArray.push(\`\${key}: \${Ext.encode(value)}\`));
        	return valuesArray.join(',');
    	},
	}
});

Requests composition

Proxy actions must be divided into queries and mutations. Read actions will produce a GraphQL query while create , update and destroy must generate a mutation. For each action a template is defined to generate the right GraphQL json payload.

Queries templates (readListTpl and readSingleTpl) are applied on model fields, sort and filter parameters in order to retrieve the exact structure described in the model.

Mutation templates (saveTpl, deleteTpl) are applied on record values to save data based on record state (it sends only modified values by default).

Queries for lists with pagination, sorting and filtering

This proxy applies the Apollo offset-based pagination. This is provided by the built-in Server proxy and configured with Apollo standard parameters. In order to get both total count and results from server response, the totalProperty and rootProperty taken from the Reader are included in the query.

Sorters and filters are sent in a query using the default ExtJS serializers that produce a JSON string representing the list of sorters/filters. This is a simple approach that offers a full integration with ExtJs elements. If the GraphQL server needs a structured schema (instead of a string) for sorters and filters, different encodeSorters and encodeFilters can be implemented.

The fields to select in the query are taken directly from the model in the getFields private method. In this implementation associations are supported by retrieving fields recursively from referenced models. By default, all calculated fields are ignored.

Mutations for create, update and destroy

The values to send for create and update mutations are retrieved in the getValues private method. Different options can be sent to record.getData in order to customize what values to send.

Reading responses

All the responses from GraphQL APIs can be read using the built-in proxy logic using the processResponse method. This ensures to be completely compatible with ExtJS stores and then with UI components.

In order to parse the data, a simple Reader implementation is needed because Apollo Client parse the response by itself therefore there is no need to redo the task.

Ext.define('GraphQLApp.proxy.GraphQLReader', {
	extend: 'Ext.data.reader.Json',
	alias: 'reader.graphql',
 
	read: function (response, readOptions) {
    	if (response && response.data) {
        	// NOTE Clone is needed to prevent conflicts with Apollo client
        	const responseObj = Ext.clone(Ext.Object.getValues(response.data)[0]);
        	if (this.getTotalProperty() && responseObj.hasOwnProperty(this.getTotalProperty())) {
            	// List response
            	return this.readRecords(responseObj, readOptions);
        	} else {
            	// Single record response
            	return this.readRecords([responseObj], readOptions);
        	}
    	} else {
        	return new Ext.data.ResultSet({
            	total: 0,
            	count: 0,
            	records: [],
            	success: false,
            	message: response.errors
        	});
    	}
	},
});

Define a Model

In the interest of defining the models, we just need to use the GraphQL proxy and describe the fields reproducing the GraphQL schema we want to connect with. In this article we define the User model with a one-to-many association with Area.

In order to generate GraphQL requests we just need to specify in the proxy configuration the root level names for queries and mutations.

Ext.define('GraphQLApp.model.Area', {
	extend: 'Ext.data.Model',
 
	fields: [{
    	name: 'id',
    	type: 'int'
	}, {
    	name: 'name',
    	type: 'string'
	}],
 
	proxy: {
    	type: 'graphql',
    	query: {
        	list: 'getAreas',
        	get: 'area'
    	},
    	reader: {
        	type: 'graphql',
        	rootProperty: 'areas',
        	totalProperty: 'count'
    	}
	},
});
Ext.define('GraphQLApp.model.User', {
	extend: 'Ext.data.Model',
 
	fields: [{
    	name: 'id',
    	type: 'int'
	}, {
    	name: 'username',
    	type: 'string'
	}, {
    	name: 'email',
    	type: 'string'
	}, {
    	name: 'firstName',
    	type: 'string'
	}, {
    	name: 'lastName',
    	type: 'string'
	}, {
    	name: 'role',
    	type: 'string'
	}, {
    	name: 'areas',
   	 reference: {
        	type: 'GraphQLApp.model.Area'
    	}
	}],
 
	proxy: {
    	type: 'graphql',
    	query: {
        	list: 'getUsers',
        	get: 'user'
    	},
    	mutation: {
        	create: 'createUser',
        	update: 'updateUser',
        	destroy: 'deleteUser',
    	},
    	reader: {
        	type: 'graphql',
        	rootProperty: 'users',
        	totalProperty: 'count'
    	}
	},
});

UI binding

The UI integration is totally transparent as the stores and models that use the GraphQL proxy do not change their behaviour. These are some examples that trigger queries and mutations.

Grid with pagination, sorting and filtering

Ext.define('GraphQLApp.view.main.MainView', {
	extend: 'Ext.Panel',
	xtype: 'mainview',
	requires: [
    	'Ext.grid.plugin.PagingToolbar'
	],
 
	layout: 'fit',
 
	items: [{
    	xtype: 'grid',
    	store: {
        	model: 'GraphQLApp.model.User',
        	autoLoad: true,
        	remoteSort: true,
    	},
    	plugins: {
        	pagingtoolbar: true
    	},
    	columns: [
        	{ text: 'Id', dataIndex: 'id', width: 100 },
        	{ text: 'Username', dataIndex: 'username', flex: 1 }
    	]
	}]
})

Load single record and update

GraphQLApp.model.User.load(1, {
	success: function (record, operation) {
    	record.set('firstName', 'Foo');
    	record.save();
	},
});

GitHub repository

The full working example can be found here.