How-to: Фронтенд
Наиболее простым способом организации взаимодействия React приложения с приложением на базе lsFusion является общение через HTTP-протокол посредством JSON API. Для выгрузки списка объектов по заданному условию удобнее всего использовать интерфейс, который находится по Url'у eval/action. На него в BODY можно передать программный код lsFusion, который будет выполнен. При необходимости вернуть данные по запросу необходимо использовать оператор EXPORT
. По умолчанию, он возвращает данные в формате JSON, которые затем легко обрабатываются при помощи JavaScript.
В данных примерах мы будем делать запросы без авторизации. Для того, чтобы сервер начал принимать запросы без авторизации, в форме Администрирование > Настройки
на вкладке Параметры
нужно установить опцию enableAPI
в значение 2
.
Более безопасным способом будет создание для каждого запроса отдельного действия с пометкой @@api и установкой enableAPI
в значение 0
. В таком случае, сервером будут приниматься запросы только к этим действиям и при авторизованном пользователе. Внутри этих действий перед началом обр аботки можно проверять на доступность его пользователю, которого можно получить при помощи свойства currentUser()
.
Пример 1
Для наглядности реализуем простую форму из примера Турнирная таблица (без возможности редактирования).
Оформим считывание плоского набора данных при помощи функции с одним параметром : текстом запроса, к которому добавляется слева EXPORT FROM
:
const url = "https://demo.lsfusion.org/hockeystats/eval/action";
function select(script) {
const params = {
method: "post",
body: "EXPORT FROM " + script
}
return fetch(url, params).then(response => response.json());
}
Например, запрос следующего содержания
select("date(Game g), hostTeamName(g), hostGoals(g), guestGoals(g), guestTeamName(g), resultName(g)")
вернет JSON вида
[
{
"date":"05.02.19",
"hostGoals":3,
"guestTeamName":"New York Rangers",
"hostTeamName":"Detroit Red Wings",
"guestGoals":2,
"resultName":"ПО"
},
{
"date":"13.02.19",
"hostGoals":2,
"guestTeamName":"Toronto Maple Leafs",
"hostTeamName":"Montreal Canadiens",
"guestGoals":0,
"resultName":"П"
},
{
"date":"15.02.19",
"hostGoals":3,
"guestTeamName":"Montreal Canadiens",
"hostTeamName":"New York Rangers",
"guestGoals":5,
"resultName":"П"
},
{
"date":"17.02.19",
"hostGoals":2,
"guestTeamName":"Detroit Red Wings",
"hostTeamName":"Toronto Maple Leafs",
"guestGoals":1,
"resultName":"ПБ"
}
]
Остается только подложить эти данные в качестве состояний для React компонент (например, с использованием Material-UI) :
Пример 2
Рассмотрим немного более сложный случай, когда нужно делать параметризованные запросы, в зависимости от выбранных пользователем данных. В качестве логики backend'а возьмем пример Управление материальными потоками.
Предположим, что нужно построить форму, в которой нужно показать расходные документы, с возможностью фильтровать по дате и складу. При этом, когда пользователь выбирает конкретный документ, должны отобразиться его строки.
Для реализации запроса к backend'у по получению расходных документов с фильтрацией объявим функцию:
const url = "https://demo.lsfusion.org/mm/eval/action";
function select(script, data) {
var formData = new FormData();
formData.append("script", "EXPORT FROM " + script);
for (var name in data) {
formData.append(name, data[name]);
}
const params = {
method: "post",
headers: {
"Content-type": "multipart/form-data"
},
body: formData
};
return fetch(url, params).then(response => response.json());
}
Первая функция select
будет делать POST запрос с типом содержимого multipart/form-data, передавая первым параметром на сервер текст запроса, а остальными параметрами - значения запроса.
Например, вызов функции вида
select("id = Shipment s, number(s) WHERE date(s) = $1", { date: new Date().toISOString().substr(0, 10) })
вернет все расходные документы за сегодняшнее число. Следует отметить, что название параметра date не используется на backend'е и может быть абсолютно любым. Важен только порядок следования параметров.
Для удобства также объявим функцию, которая будет формировать запрос, добавляя в фильтр только те параметры, значения которых не являются null
:
function selectWhere(script, wheres) {
var exprs = [], params = {};
for (var i = 0; i < wheres.length; i++) {
if (wheres[i].value != null) {
exprs.push(wheres[i].expr + "=$" + (i + 1));
params = { ...params, ...{ ["p" + i]: wheres[i].value } };
}
}
return select(script + (exprs.length > 0 ? " WHERE " : "") + exprs.join(" AND "), params);
}
Соответствующий вызов функции выше может быть заменен на:
selectWhere("id = Shipment s, number(s)", [{ expr: "date(s)", value : new Date().toISOString().substr(0, 10) }])
Используя описанные выше запросы реализуем требуемую логику. Для этого определим две компоненты:
Shipments
, которая будет отображать при помощи компонента List (из упомянутого Material-UI) список документов. Также в ней будет содержаться компонентFilters
, при помощи которого пользователь будет задавать параметры для фильтрации.Details
, который будет отображать строки выбранного докуме нта. При желании его можно было бы вложить в компонентShipments
.
Остальной код будет выглядеть следующим образом:
Пример 3
Реализуем простой CRUD интерфейс для ввода товаров.
Так как для этого понадобится изменять информацию в базе данных, то объявим функцию, которая будет выполнять произвольный код на платформе (с обработкой ошибок):
function handleErrors(response) {
if (!response.ok) {
response.text().then(text => console.log(text));
throw Error(response);
}
return response;
}
function evaluate(script, data) {
var formData = new FormData();
formData.append("script", script);
for (var name in data) {
formData.append(name, data[name]);
}
const params = {
method: "post",
headers: {
"Content-type": "multipart/form-data"
},
body: formData
};
return fetch(url, params).then(handleErrors);
}
Например, для создания товара в базе данных нужно будет выполнить следующий код :
evaluate("NEW s = Item { name(s) <- $1; barcode(s) <- $2; salePrice(s) <- $3; APPLY; }", { name : "My item", barcode : "4341", salePrice : 2.34 } )
Как и в примерах выше, названия параметров не важны, а важен их порядок. Оператор APPLY
сохраняет изменения в базу данных. Без него данные сохранены не будут и сбросятся по завершению выполнения запрос.
Для изменения атрибутов товара можно использовать следующий код (где id
- это внутренний идентификатор товара) :
evaluate("FOR Item s = $0 DO { name(s) <- $1; barcode(s) <- $2; salePrice(s) <- $3; APPLY; }", { id : 32494, name : "My item", barcode : "4341", salePrice : 2.34 })
Для удаления заданного товара:
evaluate("DELETE s WHERE s AS Item = $1; APPLY; ", { id : 32494 })
Код по реализации непосредственно пользовательского интерфейса может выглядеть следующим образом:
Пример 4
В этом примере реализуем возможность просмотра и редактирования списка документов со строками.
Для этого необходимо будет передавать на сервер набор строк конкретного документа. Чтобы не делать отдельные запросы для каждых строк и производить все изменения одним запросом, будем передавать их параметром в JSON формате в виде файла. Для этого модифицируем нашу функцию evaluate
следующим образом :
function isObject(obj) {
return obj === Object(obj);
}
function evaluate(script, data) {
var formData = new FormData();
formData.append("script", script);
console.log(data);
for (var name in data) {
if (isObject(data[name]))
formData.append(
name,
new Blob([JSON.stringify(data[name])], { type: "application/json" })
);
else formData.append(name, data[name]);
}
const params = {
method: "post",
headers: {
"Content-type": "multipart/form-data"
},
body: formData
};
return fetch(url, params).then(handleErrors);
}
Теперь он смотрит, что если в объекте data
в качестве поля передается объект, то он преобразуется в JSON и передается как файл с типом application/json.
Для того, чтобы принять этот файл последним параметром (в данном примере $5
) и обновить строки путем удаления старых и создания новых, будем использовать следующий код на платформе :
IMPORT JSON FROM $5 AS FILE FIELDS LONG item, NUMERIC[16,3] quantity, NUMERIC[16,3] price, NUMERIC[16,3] sum DO
NEW d = ShipmentDetail {
shipment(d) <- s;
item(d) <- GROUP MAX i AS Item IF LONG(i) = item;
quantity(d) <- quantity;
price(d) <- price;
sum(d) <- sum;
}
Для разбора JSON используем специальный оператор IMPORT
. Он предполагает, что ему на вход будет плоский JSON в виде массива объектов с числовыми полями item
, quantity
, price
и sum
. Для каждого объекта в нем будет создан свой ShipmentDetail
. Затем он привязыва ется к соответствующему объекту s
класса Shipment
. После этого проставляется товар, у которого внутренний код совпадает с переданным, и остальные свойства. Разбор более сложных JSON можно найти в примерах How-to: Импорт данных и How-to: Взаимодействие через HTTP-протокол.
При сохранении изменений может нарушиться ограничение. В этом случае изменения не будут сохранены в базу данных (при этом они останутся в сессии изменений). При этом в свойство canceled
будет записано TRUE
, а в свойство applyMessage
сообщение ограничения. Для того, чтобы обработать эту ситуацию будем использовать следующий код на платформе:
APPLY;
IF canceled() THEN
EXPORT FROM message = applyMessage();
Если изменения не прошли, то сообщение возвращается в BODY ответа в виде JSON с единственным полем applyMessage
.
Общий код, который передается в evaluate
для, например, создания документа будет выглядеть следующим образом :
NEW s = Shipment {
number(s) <- $1; date(s) <- $2; customer(s) <- $3; stock(s) <- $4;
IMPORT JSON FROM $5 AS FILE FIELDS LONG item, NUMERIC[16,3] quantity, NUMERIC[16,3] price, NUMERIC[16,3] sum DO
NEW d = ShipmentDetail {
shipment(d) <- s;
item(d) <- GROUP MAX i AS Item IF LONG(i) = item;
quantity(d) <- quantity;
price(d) <- price;
sum(d) <- sum;
}
APPLY;
IF canceled() THEN
EXPORT FROM message = applyMessage();
}
Для редактирования (в нем предварительно удаляются уже существующие строки), соответственно:
FOR Shipment s = $0 DO {
number(s) <- $1; date(s) <- $2; customer(s) <- $3; stock(s) <- $4;
DELETE ShipmentDetail d WHERE shipment(d) = s;
IMPORT JSON FROM $5 AS FILE FIELDS LONG item, NUMERIC[16,3] quantity, NUMERIC[16,3] price, NUMERIC[16,3] sum DO
NEW d = ShipmentDetail {
shipment(d) <- s;
item(d) <- GROUP MAX i AS Item IF LONG(i) = item;
quantity(d) <- quantity;
price(d) <- price;
sum(d) <- sum;
}
APPLY;
IF canceled() THEN
EXPORT FROM message = applyMessage();
}
Для того, чтобы обработать ошибку о нарушении ограничения (так как статус ответа в обоих случаях в данной реализации будет 200), будем считать, что если BODY ответа пустое, то ошибки нету. Если есть какой-то текст, то это JSON с текстом ограничения. Например, можно использовать следующий код:
evaluate("...").then(response =>
response.text().then(text => {
if (text === "") {
// ok
} else {
console.log(JSON.parse(text).applyMessage);
}
})
);
Реализация всей задачи может выглядеть следующим образом :
Пример 5
По аналогии с Примером 1 реализуем форму Турнирная таблица с возможностью редактирования при помощи Form API. Для его использования требуется подключить библиотеку @lsfusion/core.
В первую очередь нужно инициализировать форму при помощи функции create
после загрузке основного компонента :
componentDidMount() {
create(updateState => this.setState(updateState), url, {
name: "MainForm"
});
}
Первым параметром передается callback функция, в которую после ответа с сервера будет передано начальное состояние формы:
{
"game":{
"list":[
{
"date":"05.02.19",
"hostGoals":3,
"guestTeamName":"New York Rangers",
"hostTeamName":"Detroit Red Wings",
"guestGoals":2,
"value":6054,
"resultName":"ПО"
},
{
"date":"13.02.19",
"hostGoals":2,
"guestTeamName":"Toronto Maple Leafs",
"hostTeamName":"Montreal Canadiens",
"guestGoals":0,
"value":6063,
"resultName":"П"
},
{
"date":"15.02.19",
"hostGoals":3,
"guestTeamName":"Montreal Canadiens",
"hostTeamName":"New York Rangers",
"guestGoals":5,
"value":6072,
"resultName":"П"
},
{
"date":"17.02.19",
"hostGoals":2,
"guestTeamName":"Detroit Red Wings",
"hostTeamName":"Toronto Maple Leafs",
"guestGoals":1,
"value":6075,
"resultName":"ПБ"
}
],
"value":6054
},
"team":{
"list":[
{
"gamesLostSO":0,
"goalsConceded":3,
"gamesLostOT":0,
"goalsScored":7,
"gamesWon":2,
"points":6,
"gamesWonOT":0,
"gamesLost":0,
"gamesPlayed":2,
"name":"Montreal Canadiens",
"gamesWonSO":0,
"place":1,
"value":6064
},
{
"gamesLostSO":1,
"goalsConceded":4,
"gamesLostOT":0,
"goalsScored":4,
"gamesWon":0,
"points":3,
"gamesWonOT":1,
"gamesLost":0,
"gamesPlayed":2,
"name":"Detroit Red Wings",
"gamesWonSO":0,
"place":2,
"value":6057
},
{
"gamesLostSO":0,
"goalsConceded":3,
"gamesLostOT":0,
"goalsScored":2,
"gamesWon":0,
"points":2,
"gamesWonOT":0,
"gamesLost":1,
"gamesPlayed":2,
"name":"Toronto Maple Leafs",
"gamesWonSO":1,
"place":3,
"value":10993
},
{
"gamesLostSO":0,
"goalsConceded":8,
"gamesLostOT":1,
"goalsScored":5,
"gamesWon":0,
"points":1,
"gamesWonOT":0,
"gamesLost":1,
"gamesPlayed":2,
"name":"New York Rangers",
"gamesWonSO":0,
"place":4,
"value":6061
}
],
"value":6064
}
}
В возвращаемом JSON есть также другие служебные поля.
Таблицы объектов можно считывать из game.list
и team.list
соответственно. Текущие объекты хранятся в game.value
и team.value
. Эти данные мы используем для формирования двух таблиц с играми и командами. Для отметки текущего ряда используется функция equals, которая сравнивает, например, значения из game.value
и game.list[<row>].value
. Это нужно, так как в value
могут быть более сложные объекты, если в группе объектов объявлено несколько объектов.
До тех пор, пока форма не загружена вспомогательная функция formCreated
возвращает false
, и таблицы не отображаются.
if (!formCreated(this.state)) return <div>Loading</div>;
Также при помощи функции numberOfPendingRequests
мы выводим на форму количество запросов на сервер, на которые не получен еще ответ.
<div>
{numberOfPendingRequests(this.state) > 0
? "Loading, number of requests : " +
numberOfPendingRequests(this.state)
: ""}
</div>
Изменение текущего объекта на форме и значений свойств осуществляется при помощи функции change
.
change(updateState => this.setState(updateState), changes);
Она принимает на вход callback, который изменит текущее состояние и объект, хранящий список изменений. При таком использовании, после каждого вызова change
текущее состояние формы (объект App) будет постоянно обновляться новыми значениями с учетом сделанных ранее пользователем изменений.
Например, для изменения текущей выбранной игры можно использовать следующий вызов :
change(updateState => this.setState(updateState), { game : { value : 6063 } });
Здесь 6063 - это value
выбираемого объекта.
Для изменения значения свойства можно использовать следующий код:
change(updateState => this.setState(updateState), { game : { value : 6063, hostGoals : 3 } });
Этот вызов изменит текущий объект на игру с идентификатором 6063 и значение голов хозяев на переданное значение (3), Если не передавать тэг value
, то изменение голов будет сделано для ранее выбранной игры.
Такая же схема используется для изменений свойств, которые не являются первичными, а являются простой композицией от объектных свойств:
change(updateState => this.setState(updateState), { game : { value : 6063, hostTeamName: "Montreal Canadiens" } });
Система сама найдет команду с заданным именем и изменит значение команды хозяйки на найденное.
Чтобы сделать выбор команды из списка можно использовать компонент react-select. Чтение множества команд можно организовать при помощи объявленной в первом примере функции select
.
Ниже показан полностью работающий исходный код: