Pokemon Game

Remote Fetch + Projection Example

This example shows how to fetch remote data and map it into multiple fields when a user selects a value.


What this example demonstrates

  • Remote API fetch
  • Mapping API response to fields (projection)
  • Updating multiple fields from one request
  • Calculated fields
  • Event → Trigger → Action flow

Basic Idea

Change → Fetch → Map Response → Update Fields → Recalculate

Everything is configured declaratively in the field model.


Triggering a Remote Fetch

When the user selects a value, a fetch action is triggered.

{
triggers: [
  {
    on: 'change',
    action: 'fetch',
    fetchUrl: '$value',
    projection: {
      hp: 'stats[0].base_stat',
      attack: 'stats[1].base_stat',
      defense: 'stats[2].base_stat',
      image: 'sprites.other.showdown.front_default'
    }
    source: 'type',
  }
]
}

The engine will:

  1. Call the API
  2. Read the response
  3. Map values into fields
  4. Update the UI

Projection (Mapping API Response)

Projection maps API response fields into form fields.

{
projection: {
  hp: 'stats[0].base_stat',
  attack: 'stats[1].base_stat',
  defense: 'stats[2].base_stat',
  image: 'sprites.other.showdown.front_default'
}
}

This allows updating multiple fields from a single API response without writing component code.


Calculated Fields

Fields can calculate values based on other fields.

new OutputField({
key: 'damage',
label: 'Damage',
calculation: 'Number(player.attack) - Number(opponent.defense) / 2'
})

Calculated fields automatically update when dependent values change.


Mental Model

Think in this pattern when building forms:

User action → Trigger → Action → State update → UI update

Examples:

  • Select value → fetch related data
  • Enter input → validate asynchronously
  • Change number → recalculate result
  • Click button → trigger action
  • Select item → load additional details

Why This Example Matters

This example demonstrates that Preforms is not just a form renderer.
It is a declarative, event-driven form engine where behavior is defined using triggers and actions instead of component code.


One Sentence Summary

When something happens, trigger an action that updates field state, and the UI updates automatically.

Live Demo

You lose!

Result

import { Component, EventEmitter, Output, ViewEncapsulation } from '@angular/core';
import { Preforms } from '@preforms/angular/core';
import { DYNAMIC_FORM_FETCHER } from '@preforms/angular/core';
import { NATIVE_FORM_ELEMENTS } from '@preforms/angular/native';
import {
  DialogField,
  FieldButton,
  FieldGroup,
  FieldWrapper,
  FormDivider,
  FormFieldEventType,
  FormImage,
  FormTitle,
  InputField,
  NumberField,
  OutputField,
  SelectField,
  TriggerAction,
} from '@preforms/ts';

import { from } from 'rxjs';

function randomIntBetween(min: number, max: number): number {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function createPlayer(playerName: string, url = '$value', showdown = 'back_default') {
  return new FieldGroup({
    key: playerName,
    hidden: true,
    fields: [
      new FormImage({
        src: '',
        key: 'image',
      }),
      new InputField({
        type: 'number',
        key: 'hp',
        value: 0,
        className: 'indicator',
        readonly: true,
      }),
      new InputField({
        type: 'number',
        key: 'attack',
        value: 0,
        className: 'indicator',
        readonly: true,
      }),
      new InputField({
        type: 'number',
        key: 'defense',
        value: 0,
        className: 'indicator',
        readonly: true,
      }),
      new NumberField({
        key: 'hit',
        readonly: true,
        className: 'hit',
      }),
      new OutputField({
        key: 'damage',
        calculation: `
        ${playerName}.hit ? Number(${playerName}.defense) - Number(${playerName}.hit) : 0`,
        for: ['p1', 'p2'],
        // hidden: true,
      }),
    ],
    triggers: [
      {
        on: FormFieldEventType.SELECT,
        action: TriggerAction.FETCH,
        fetchUrl: url,
        source: 'pokemon',
        mode: 'patch',
        projection: {
          target: 'value',
          select: {
            hp: 'stats[0].base_stat',
            attack: 'stats[1].base_stat',
            defense: 'stats[2].base_stat',
            image: `sprites.other.showdown.${showdown}`,
          },
        },
      },
      {
        on: FormFieldEventType.SELECT,
        action: TriggerAction.UPDATE,
        state: {
          hidden: false,
        },
      },
    ],
  });
}

@Component({
  selector: 'app-dynamic-form-fetch-remote',
  template: `<preforms (submittedData)="logData($event)" [fields]="fields" className="pokemon" />`,
  imports: [Preforms],
  styles: [
    `
      .pokemon {
        .grid {
          max-width: 400px;
          padding: 20px;
          display: grid;
          grid-template-areas: 'a a' 'b b';
          gap: 10px;
          grid-auto-rows: 40px 300px;
          align-items: end;
        }

        .hit {
          .preforms-input-field {
            all: unset;
          }
        }

        .indicator {
          .preforms-input-field {
            max-width: 100%;
          }
        }

        .primary {
          background-color: rgb(255, 187, 0);
          padding: 0.6rem 2rem;
          border: 1px solid #cfd6e3;
          border-radius: 25px;
          font-weight: 700;
          margin-top: 30px;
          display: inline-block;
        }
      }
    `,
  ],
  providers: [
    NATIVE_FORM_ELEMENTS,
    {
      provide: DYNAMIC_FORM_FETCHER,
      useValue: (url: string) => {
        return from(fetch(url).then((res) => res.json()));
      },
    },
  ],
  encapsulation: ViewEncapsulation.None,
})
export class DynamicFormFetchRemoteComponent {
  @Output() formChange = new EventEmitter<any>();

  fields = [
    new OutputField({
      key: 'score',
      calculation: 'Number(p1.damage) > Number(p2.damage)',
      for: ['p1', 'p2'],
      hidden: true,
    }),
    new DialogField({
      key: 'popup',
      fields: [],
      triggers: [
        {
          on: 'change',
          action: 'update',
          state: {
            fields: [new FormTitle('You win!')],
          },
          condition: true,
          source: 'score',
        },
        {
          on: 'change',
          action: 'update',
          state: {
            fields: [new FormTitle('You lose!')],
          },
          condition: false,
          source: 'score',
        },
      ],
    }),

    new FieldWrapper({
      className: 'grid',
      fields: [
        new FormDivider({ label: 'Player' }),
        new FormDivider({ label: 'Enemy' }),
        createPlayer('p1'),
        createPlayer(
          'p2',
          'https://pokeapi.co/api/v2/pokemon/' + randomIntBetween(1, 151),
          'front_default',
        ),
      ],
    }),
    new FormDivider({ label: 'Play!' }),
    new SelectField({
      key: 'type',
      label: 'Select Pokemon Type',
      options: [],
      triggers: [
        {
          on: FormFieldEventType.INIT,
          action: TriggerAction.FETCH,
          fetchUrl: 'https://pokeapi.co/api/v2/type/',
          mode: 'patch',
          projection: {
            target: 'options',
            select: {
              value: 'url',
              label: 'name',
            },
            source: 'results',
          },
        },
      ],
    }),
    new SelectField({
      key: 'pokemon',
      label: 'Select Pokemon',
      hidden: true,
      options: [],
      triggers: [
        {
          on: FormFieldEventType.CHANGE,
          source: 'type',
          action: TriggerAction.FETCH,
          fetchUrl: '$value',
          mode: 'patch',
          projection: {
            target: 'options',
            select: {
              value: 'pokemon.url',
              label: 'pokemon.name',
            },
            source: 'pokemon',
          },
        },
        {
          on: FormFieldEventType.SELECT,
          source: 'type',
          action: TriggerAction.UPDATE,
          state: {
            hidden: false,
          },
        },
      ],
    }),
    new FieldButton({
      type: 'button',
      label: 'Attack!',
      hidden: true,
      className: 'primary',
      triggers: [
        {
          on: 'select',
          source: 'pokemon',
          action: 'update',
          state: {
            hidden: false,
          },
        },
        {
          on: 'click',
          action: 'update',
          state: {
            value: {
              hit: 'p1.attack',
            },
          },
          target: ['p2'],
        },
        {
          on: 'click',
          action: 'update',
          state: {
            value: {
              hit: 'p2.attack',
            },
          },
          target: ['p1'],
        },
        {
          on: 'click',
          action: TriggerAction.OPEN_DIALOG,
          target: 'popup',
          debounce: 500,
        },
      ],
    }),
  ];

  logData(data: any) {
    console.log(data);

    // this.formChange.emit(data);
  }
}