pmcelhaney / FDL

Digital UX Field Definition Language

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

FDL

Jack Henry's Digital UX Field Definition Language

Field Types

A field type is the special sauce that gives an ordinary piece of data some flavor by defining modifiers, rules and descriptors.

For example, suppose we want to define a type of "number" and give it the following properties:

  1. If the number is in a form field, validate that the entry is a valid number.
  2. If a number shows up in a table cell, it will have the class "numeric" (<td class="numeric">).
  3. If that number happens to be negative, it will also have a class of negative (<td class="numeric negative">)

FieldType allows us to encode these rules in a declarative API that reads like a sentence.

const number = new FieldType().with
    .validator(mustBeNumeric)
    .and.cellClass('numeric')
    .and.conditionalCellClass(isNegative, 'negative');

The variables mustBeNumeric and isNegative each point to functions (not shown here) that take the value as an argument and return true or false. Callback functions are used extensively in FieldType definitions to add new behaviors without touching existing code1.

The with and and properties are there to help the code read more like a sentence. You can use with, and, and thatIs interchangeably. They all point to the same object, a FieldTypeBuilder.

FieldTypes are immutable. Each of the functions, which we'll call modifiers, returns a new object. As such, we're able to build a new field type out of an existing field type, ala the prototype pattern.

const money = number.with
  .formatter(usdFormat)
  .and.formatter(accountingNotation)
  .and.parser(reverseUsdFormat)
  .and.parser(reverseAccountingNotation)
  .and.parser(parseFloat)
  .and.minColumnWidth(50)
  .and.targetColumnWidth(80)
  .and.maxColumnWidth(200);

Reusing field types cuts down on the amount of code we have to maintain while keeping business rules separate from presentation code. It also ensures that the business rules are implemented consistently -- if a rule changes, we only have to change the code in one place.

What about special cases where we need a field type to have a slightly different behavior on one screen only? These cases are quite common, and there is an elegant solution. Because a modifier always produces a new FieldType, we can start with a basic, reusable FieldType and add the special behaviors inline!

import { name, address, date, accountNumber } from 'my-field-library/fieldTypes';
import { accountService } from 'services/account';

const transferRecord = new Record({
    beneficiaryName: name,
    beneficiaryAddress: address,
    date: date.with.angularValidator(isValidTransferDate)
    fromAccount: accountNumber.with.options(accountService.getAvailableFromAccounts),
    toAccount: accountNumber.with.options(
        (record) => accountService.getAvailableToAccounts().then(
            (accounts) => accounts.filter(
                (account) => account.number !== record.getField('fromAccount'))))
})

1. i.e. the open-closed principle

export function Modifiers() {
  return html`<!-- this is a hack to make "modifiers" show up in the menu -->`;
}

Modifiers

The following modifiers are available to extend a FieldType.

Name Description
compareFunction((a, b) => number) A function to compare to values for sorting
hashFunction(fn) A function to compare to values for setting display
disabledWhen(predicate) Indicates when a form field should be disabled
emptyWhen(value => boolean) Condition under which the field would be considered empty if required
filter(predicate) Used for type-to-search
formatter(fn) Applies formatting to a value wherever it's output
label(string | (record, labelSoFar) => string) The label associated with a form field
lookup(asyncFn) Attaches a button to a form field that launches a lookup dialog
maxColumnWidth(pixels) The maximum column width in a table
minColumnWidth(pixels) The minimum column width in a table
maxLength(int) The maximum input length in a text field
minLength(int) The minimum input length in a text field
multipleValues The field can have zero or more values (i.e. multi-select)
options(config) List of options (for a dropdown control)
parser(fn) Parse text input (inverse of formatter)
readOnlyWhen(predicate) Whether to render a read-only value instead of a form field
schema(string) A hack there we're hoping to eliminate soon
search(config) Used for type-to-search within options
suggestions(asyncFn) Provide combobox-style suggestions
targetColumnWidth(pixels) The ideal column width in a table
validator(validatorObject[, config]) Used to create client-side validation rules
visibleWhen(predicate) Whether the field should be visible

compareFunction((a, b) => number)

Used for sorting. compare is a function that takes two arguments, compares them, and returns -1, 0, or 1, i.e. the same type of function that can optionally be passed to Array#sort.

function caseInsensitiveSort(a, b) {
  if (a.toLowerCase() < b.toLowerCase()) {
    return -1;
  }
  if (a.toLowerCase() > b.toLowerCase()) {
    return 1;
  }

  return 0;
}

const example = new FieldType().with.compareFunction(caseInsensitiveSort);

hashFunction(compare)

Used for selecting an option in a select where the object needs selection based on a unique property value.

function functionHasUniqueName(a) {
  return a.name;
}

const example = new FieldType().with.hashFunction(functionHasUniqueName);

disabledWhen(condition)

Causes an input field to be disabled when the condition(record) returns true. disabledWhen() functions can be stacked and will disable the field when any condition is true. You can use the shorthand .disabled() to disable the field.

const example = username.thatIs
  .disabledWhen((record) => typeof record.getField("company") === "undefined")
  .and.disabledWhen((record) => !record.getField("userId"));
const shortHand = username.thatIs.disabled();

emptyWhen(val => boolean)

Causes an input field to fail the required validator condition(value) returns true. Values null, undefined, and '' (zero-length string) are considered empty by default. Use emptyWhen() to assign any additional values under which the field would be considered empty, such as 0.

const example = username.thatIs.emptyWhen((val) => val === 0);

filter()

Makes the field type filterable (with type to filter). By default a field type is not filterable and will be considered to match any substring. When called without arguments, filter() will make the field match when the search text is a case-insensitive substring of the value.

const text = new FieldType().with.filter();
text.match("world", "Hello World"); // true
text.match("hi", "Hello World"); // false

filter() can take an optional callback function which provides a more refined match function

const number = new FieldType().with.filter((searchText, value) => {
  if (searchText[0] === "<") return value < parseFloat(searchText.slice(1));
  if (searchText[0] === ">") return value > parseFloat(searchText.slice(1));
  if (searchText[0] === "=") return value === parseFloat(searchText.slice(1));
  if (searchText.slice(0, 2) === "<=")
    return value <= parseFloat(searchText.slice(2));
  if (filter.slice(0, 2) === ">=")
    return value >= parseFloat(searchText.slice(2));
  return true;
});

number.match("<5.00", 5.75); // false
number.match(">=1200", 1200); // true

formatter(fn)

Applies formatting to a value wherever it's output. Note that formatters can be composed. If a FieldType has multiple formatters, the output of one formatter becomes the input of the next formatter.

const usdFormat = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

function formatUsd(value) {
  return usdFormat.format(value);
}

function accountingNotation(value) {
  const valueAsString = value.toString();
  if (valueAsString[0] === "-") {
    return `(${valueAsString.slice(1)})`;
  }
  return valueAsString;
}

const money = number.with
  .formatter(formatUsd)
  .and.formatter(accountingNotation);

Specifically, it affects the output of Record#print().

Wherever there is a a formatter() for a field that can be used in input, you should pair it with a parser() to do the reverse translation.

Note: Should we add a second argument, appliesToInput? It doesn't make sense for accounting notation to be used in input. However, we have no use case currently for a user inputting a negative value, so the point is moot.

const record = new Record(
  {
    balance: money,
  },
  { value: -123 }
);

record.print("balance"); // ($123.00)

label(string | (record, labelSoFar) => string)

label() provides a label to your gateway / form element component. A label can be simply passed as a string:

const example = new FieldType().with.label("Name");

or as a function which expects a record and/or the label calculated up to this point:

const example = new FieldType().with.label((record) =>
  record.getField("company") ? "Name" : "ID Number"
);
const example = new FieldType().with.label((_, labelSoFar) =>
  labelSoFar.toLowerCase()
);

lookup(doLookup)

Takes an async function which returns a value. The function is responsible for opening a dialog and / or whatever else it takes to complete the lookup and fill in an input.

const example = new FieldType().with.lookup(getAccountNumberFromUser);

The idea here is based on hexagonal architecture. The application doesn't distinguish between getting data from a user, a service that's operated by a computer, or a test.

minColumnWidth(width) / maxColumnWidth(width) / targetColumnWidth(width)

These modifiers do exactly what's on the label: they specify the minimum, maximum, and target width of each column in pixels.

const example = new FieldType().with
  .minColumnWidth(50)
  .and.maxColumnWidth(200)
  .and.targetColumnWidth(100);

minLength(n) and maxLength(n)

adds the minLength and maxLength attributes to an input and validators to support the conditions

const example = username.with.minLength(5).and.maxLength(20);

multipleValues()

Causes the FieldType to have an array of values instead of just one value (e.g. changes a dropdown to a multiple-select dropdown with checkboxes.

In the future, we may consider adding optional parameters for minValueCount and maxValueCount.

const example = account.with.options(/* ... */).and.multipleValues();

options(config)

Provides a list of options for input (i.e. dropdown control)

config is an object with the following properties:

  • data or fetch (one or the other is required, with data taking precedence)
    • data: an array of source data
    • fetch: a function that takes the record values as an object and returns a promise with source data
  • text (default = "text")
    • a string corresponding to the key containing display text (e.g. "name"), or
    • a function that takes the source object and returns the display text (e.g. user => user.name + ' ' +user.userId
  • value (default = "value")
    • a string corresponding to the key containing value (e.g. "id"), or
    • a function that takes the source object and returns the value (e.g. user => user.id)
  • sort (default = undefined)
  • fields (default = undefined)
    • an array of field names that, when changed, can cause the options to be updated
    • only applies when fetch is used
    • used for performance tuning
      • to prevent your select from looking up new options when nothing significant changed
      • to cache (memoize) the results and not look up options for the same input twice
  • hideSelectAll (default = false)
    • When rendering a select list, hide the "Select All" option.

Shorthand APIs

.with.options(array) // equivalent to .with.options({ data: array })
.with.options(fn) // equivalent to .with.options({fetch: fn});

Examples

account.with.options({
  data: accounts,
  value: "accountId",
  text(account) {
    return account.nickname || account.maskedAccountNumber;
  },
});

companyUser.with.options({
  fetch(values) {
    return companyService.fetchUsers(values.companyID);
  },
  fields: ["companyId"],
  sort(a, b) {
    if (a.name < b.name) return -1;
    if (a.name > b.name) return 1;
    return 0;
  },
});

parser(fn)

Used to translate user input to an unambiguous machine-readable value. One of the primary use cases for parser() is to rewind a formatter().

function parseUsd(s) {
  return s.replace(/\$|,/g, "");
}

function formatUsd(n) {
  // see implementation under formatter()
}

const money = new FieldType().with.formatter(formatUsd).and.parser(parseUsd);

readOnlyWhen(condition)

Renders the print() and formatted value of the field type when condition(record) returns true field.readonly() is false by default. You may use the shorthand readOnly() to set the value to always true

const example = username.thatIs.readOnlyWhen(
  (record) => record.getField("company") === "Joe's Crab Shack"
);
const shortHand = username.thatIs.readOnly();

schema(name)

Deprecated. If all goes well, this hack will not survive the conversion to Lit. Instead, FieldType will take a base JavaScript datatype in its constructor, similar to the way Lit properties are configured. The "range" schemas are no longer needed; CompositeFieldType gets the job done with infinitely more flexibility.

The name is a string that must be one of the following values:

  • "boolean" (toggle)
  • "datepicker"
  • "range"
  • "date-range"

search(config)

Takes an object with properties title, columns, and filters to render a dialog, filter bar and table from which a user can select a value. Currently only works in conjunction with options() that provide the data needed to build a recordset and table.

const withSearch = new FieldType().with
  .options({ id: 123, name: "John Smith" })
  .and.search({
    title: "Search Users",
    columns: [
      { label: "User ID", field: "id" },
      { label: "User Name", field: "name" },
    ],
  });

suggestions(fetchSuggestions)

Provides a list of options matching what the user typed. The fetchSuggestions function takes the typed text and returns an array of values, each of which is either:

default function accountSuggestions(text) {
    return fetch(`/services/suggestAchCompany?text=${text}`)
        .then(response =>
            response.json().map(item => {
                return {
                    text: item.achCompanyName,
                    value: item.achCompanyName,
                };
            })
        );
}

const example = new FieldType().with.suggestions(accountSuggestions)

Note. We need to provide an option for select and multiple select to not have search boxes. Would it make sense to use suggestions() as the signal that it should have a search box? .with.options(...) = no search box, .with.options(...).and.suggestions() = search box.

requiredWhen(condition)

Causes an input field to be required when the condition(record) returns true. field.required() is false by default. You may use the shorthand required() to set the value to always true.

const example = username.thatIs.requiredWhen((record) =>
  record.getField("company")
);
const shortHand = username.thatIs.required();

validator(validatorObject [, config])

Provides a validation rule. The first argument is an object containing a name and a function called validator. When validation is needed, the validator function will be called with value (both modelValue and viewValue) the record, and the optional second parameter, config. If the function returns true, the value is valid. Here's a simple example that validates a number is greater than zero.

const greaterThanZero = {
  name: "positive",
  validate(modelValue /* viewValue , record, config */) {
    return modelValue > 0;
  },
};

const positiveNumber = number.with.angularValidator(greaterThanZero);

Notice that 3 of the 4 arguments to validate() were not used. To demonstrate why the other arguments are needed (sometimes), here's an example validating that the end date is after the start date.

const dateIsAfter = {
  name: "minimum-date",
  validator(modelValue, viewValue, record, otherDateField) {
    return modelValue > record.getField(otherDateField);
  },
};

const searchRecord = new Record({
  startDate: date,
  endDate: date.with.angularValidator(dateIsAfter, "startDate"),
});

visibleWhen(condition)

Causes an input field to be visible when the condition(record) returns true. field.visible() is true by default

const example = username.thatIs.visibleWhen((record) =>
  record.getField("company")
);
import { html } from "@open-wc/demoing-storybook";

export default {
  title: "Models/Record",
  parameters: {
    componentSubtitle: "Record API",
  },
};

Record

A record is a group of named fields, described by FieldTypes, with each field having a value. In Domain Driven Design parlance, a Record is an entity. In PoEE, it's a data transfer object.

Unless you're working on the components themselves, you won't interact with Record much other than to instantiate them and pass them to field

import { name, accountNumber, money } from "FDL/fieldTypes";

const accountRecord = new Record(
  {
    // FieldTypes
    name,
    accountNumber,
    collectedBalance: money,
    availableBalance: money,
    currentBalance: money,
  },
  {
    // values
    name: "Checking",
    accountNumber: "92120391",
    collectedBalance: 1000,
    availableBalance: 1900,
    currentBalance: 900,
  }
);
<your-field .record="accountRecord" field="name" label="Name"></your-field>
<your-field
  .record="accountRecord"
  field="accountNumber"
  label="Account Number"
></your-field>
<your-field
  .record="accountRecord"
  field="collectedBalance"
  label="Balance (Col)"
></your-field>
<your-field
  .record="accountRecord"
  field="availableBalance"
  label="Balance (Avl)"
></your-field>
<your-field
  .record="accountRecord"
  field="currentBalance"
  label="Balance (Cur)"
></your-field>

Methods

field(fieldName)

reset()

Resets all values to those that were passed into the constructor.

accountRecord.setField("accountNumber", "99999");
accountRecord.getField("accountNumber"); // '99999'
accountRecord.reset();
accountRecord.getField("accountNumber"); // '92120391'

onChange(listener)

Notifies the listener when the record is changed.

accountRecord.addEventListener("change", () => console.log("changed"));
accountRecord.setField("name", "Make It Rain"); // prints 'changed' to the console

isValid()

Returns true if all of the values in the record are valid (according to their respective fieldTypes).

accountRecord.setField("name", "$aving$");
accountRecord.isValid(); // false

accountRecord.setField("name", "Savings");
accountRecord.isValid(); // true

errors() / errorCount() / hasErrors()

Here be dragons. These functions exist, but they're not used anywhere yet and the design needs to be reviewed. We will need this functionality in ACH recipients, where we have a table of recipients that can be edited, and the first column marks the recipients that have errors.

Deprecated Functions

A field object (described at the top of this page) is a much more powerful abstraction that can do all fo the below and more.

getField(fieldName)

Returns the value of the specified field.

const howMuchCanISpend = accountRecord.getField("availableBalance"); // 1900

setField(fieldName, value)

Sets the value of the specified field.

accountRecord.setField("name", "Make It Rain");

fieldTypeForField(fieldName)

Returns the FieldType corresponding to the field.

accountRecord.fieldTypeForField("currentBalance"); // returns a FieldType object

Recordset

A Recordset is an aggregate of Records that can be sorted, filtered, and paginated. In practice, it's common to start with a LocalRecordset with test data and switch to a Recordset when the API is available.

Recordset Definition

A Recordset is constructed from a set of fields and a "fetch" function that returns a promise with the data. The fetch function takes five arguments:

  • sortColumns: An array of objects, where field is the name of the field and sort is "ascending" or "descending"
  • filterTree: A LISP-inspired data structure describing all of the filters and supports nested boolean expressions. It's used by LocalRecordset and can be used by Recordset if the fetchFunction supports it.
  • startRowNumber: The first row to fetch (i.e. would be 11 if there are 10 rows per page and you want page 2)
  • rowCount: How many rows to fetch (i.e. how many rows per page)
  • parameters: key-value pair that acts as a dumbed-down version of filterTree (doesn't support nested expressions but easier to use)
const fields = {
  id,
  title: string,
  isFavorite: boolean,
  isCustom: boolean,
};

// Note: "reportsAPI" is an imaginary API that demonstrates how the fetchFunction may need to massage
// the input and output data
function fetchReports(
  sortColumns,
  filterTree,
  startRowNumber,
  rowCount,
  parameters
) {
  return reportsApi
    .fetch(
      { type: parameters.type, includeCustom: true },
      sortColumn[0].field,
      sortColumn[0].direction,
      startRowNumber,
      rowCount
    )
    .then((reports) =>
      reports.map((report) => ({
        id: report.id,
        title: report.customTitle ?? report.title,
        isFavorite: favoriteReports.includes(report.id),
        isCustom: report.isCustom,
      }))
    );
}

const reports = new Recordset(fields, fetchReports);

const reportsTable = html`<!-- -->
  <your-table .recordset=${reports}>
    <your-table-column
      field="title"
      label="Title"
      path="/reports/:id"
    ></your-table-column>
    <your-table-column field="isFavorite" label="Favorite?"></your-table-column>
  </your-table>`;

ExampleRecordset

For use in prototyping only, ExampleRecordset generates random data automatically, using FieldType#exampleValues() to produce values. The second argument is the number of values to produce.

ExampleRecordset is handicapped until we can find a better way to produce random values. We were using Chance.js, but that bloated the build. We might want to configure the build so that it substitutes a dummy object for Chance that throws an exception when any function is called, as we don't want to use example values in production. And we should look at using Faker instead of chance.

const fields = {
  id,
  title: string,
  isFavorite: boolean,
  isCustom: boolean,
};

const recordset = new ExampleRecordset(fields, 100);

About

Digital UX Field Definition Language

License:Apache License 2.0


Languages

Language:JavaScript 100.0%