Placeholders
Placeholders can be used to replace values in configuration files and are useful for to inject values from external sources into your code.
Introduction
When a developer designs an application for distribution, they generally need to be able to customise its configurations,
so we generally end up with configuration files written in yaml or json.
This configuration imposes two major constraints:
- The data structure is important and is required for the application to function properly
- The values are static and cannot be changed without modifying the configuration file.
This second point is certainly the most important, as it is necessary to allow the application to function in a totally transparent way depending on the environment in which it is evolving.
We can therefore conclude that it is necessary to be able to inject values into the configuration files.
Usage
Define a config.yaml configuration file containing a sayHello key with the value Hello {username} {emoji}.
sayHello: Hello {username} {emoji}
Static
void main() async {
  final config = await File('config.yaml').readAsYaml();
  final placeholder = Placeholder(values: {
    'emoji': '๐',
  });
  
  final value = placeholder.apply(config['sayHello']);
  print(value); // Hello {username} ๐
}
We then instantiated a Placeholder object containing a set of values to inject into our configuration file.
Finally, we applied our placeholder to our configuration value.
We can see that the emoji slot has been replaced by ๐ but not the username key. This is because the username
value has not been declared in our placeholder.
Dynamic
The Placeholder component can also be used to declare dynamic values, i.e. values that can be modified at runtime.
Let's take the example of the MessageCreate event in our Discord client.
void main() async {
  final config = await File('config.yaml').readAsYaml();
  final placeholder = Placeholder(values: {
    'emoji': '๐',
  });
  
  client.events.server.messageCreate((ServerMessage message) async {
    if (!message.authorIsBot) {
      final author = await message.resolveMember();
      
      final str = placeholder.apply(config['sayHello'], values: {
        'username': message.author.username,
      });
      
      await message.reply(content: str); // Hello John ๐
    }
  });
}
Case study
In this exercise, we will try out the example of a ticketing module.
- Sending a drop-down menu to choose a ticket type
- Creation of a chat room with specific permissions
The declaration enabling this variability is provided thanks to the notion of environment via data injection in the runtime.
Find out how environments work in the dedicated section.
Declaration
First, we will declare the environment variables required for our ticketing module to function correctly.
tickets:
  requestRole:
    name: Request role
    description: Request a role on the server
    roles:
      - 1333087322459344936
      - 1333087372446797947
ROLE_ADMIN=1333087322459344936
ROLE_MODERATOR=1333087372446797947
In this example, we declare a ticket with the unique identifier requestRole, which will be used to request a role on
the server, and we also have a list of roles authorised to handle this type of ticket.
We can now make a modification to our configuration file.
tickets:
  requestRole:
    name: Request role
    description: Request a role on the server
    roles:
      - {env.ROLE_ADMIN}
      - {env.ROLE_MODERATOR}
Data model
Let's declare the data model that allows us to interact with our configuration file.
final class Ticket {
  final String uid;
  final String name;
  final String description;
  final List<int> roles;
  Ticket({
    required this.uid,
    required this.name,
    required this.description,
    required this.roles,
  });
  factory Ticket.of(String uid, Map<String, dynamic> payload) {
    return Ticket(
      uid: payload['uid'],
      name: payload['name'],
      description: payload['description'],
      roles: List.from(payload['roles'])
        .map(Snowflake.parse)
        .toList(),
    );
  }
}
We can now load our configuration file and instantiate our data model.
void main() async {
  final config = await File('config.yaml').readAsYaml();
  
  final List<Ticket> tickets = config['tickets'].entries
    .map((entry) => Ticket.of(entry.key, entry.value))
    .toList();
  
  print(tickets);
}
Injection
At this stage, we have converted our configuration file into a data model that can be used by our application.
We now need to apply our environment variables to our entities. There are two ways of doing this:
- Conversion at instantiation of each Ticketmodel
- Conversion on reading the value
In our case, we'll opt for an injection on reading the value when choosing a ticket type from a select menu.
We'll use an interactive component, more information in the dedicated section.
final class MessageTicketComponent implements InteractiveSelectMenu {
  @override
  String get customId => 'tickets::trigger';
  
  final List<Ticket> tickets;
  MessageTicketComponent(this.tickets);
  @override
  SelectMenuBuilderContract build() {
    final builder = SelectMenuBuilder.text(customId)
      ..setPlaceholder('Please select a ticket');
    
    for (final ticket in tickets) {
      builder.addOption(
        label: ticket.name,
        value: ticket.uid,
      );
    }
    
    return builder;
  }
}
final class MessageTicketComponent implements InteractiveSelectMenu {
  @override
  String get customId => 'tickets::trigger';
  
  final List<Ticket> tickets;
  MessageTicketComponent(this.tickets);
  @override
  Future<void> handle(SelectContext ctx, values) async {
    if (ctx case ServerSelectContext ctx) {
      final ticket = tickets.firstWhere((ticket) => ticket.uid == ticketUid);
      final server = await ctx.message.resolveServer();
      
      await server.channels.create(
        ChannelBuilder.text()
          ..setName('Ticket of ${ticket.name}')
          ..setPermissionOverwrite([
            ChannelPermissionOverwrite(
              id: server.id.value,
              deny: [Permission.viewChannel],
              type: ChannelPermissionOverwriteType.role,
            ),
            for (final role in ticket.roles) {
              ChannelPermissionOverwrite(
                id: role,
                allow: [Permission.viewChannel],
                type: ChannelPermissionOverwriteType.role,
              );
            }
          ]);
    }
  }
  @override
  SelectMenuBuilderContract build() { ...}
}
final class MessageTicketComponent implements InteractiveSelectMenu {
  @override
  String get customId => 'tickets::trigger';
  
  final placeholder = EnvPlaceholder();
  
  final List<Ticket> tickets;
  MessageTicketComponent(this.tickets);
  @override
  Future<void> handle(SelectContext ctx, values) async {
    if (ctx case ServerSelectContext ctx) {
      final ticket = tickets.firstWhere((ticket) => ticket.uid == ticketUid);
      final server = await ctx.message.resolveServer();
      
      await server.channels.create(
        ChannelBuilder.text()
          ..setName('Ticket of ${ticket.name}')
          ..setPermissionOverwrite([
            for (final role in ticket.roles) {
              ChannelPermissionOverwrite(
                id: role,
                id: placeholder.apply(role),
                allow: [Permission.viewChannel],
                type: ChannelPermissionOverwriteType.role,
              );
            }
          ]);
    }
  }
  @override
  SelectMenuBuilderContract build() { ...}
}
Now that our component has been created, we need to send our menu to a target channel.
For our example, we'll use the channel in which the command will be executed.
final class TicketCommand with Component implements CommandDeclaration {
  Future<void> handle(ServerCommandContext ctx) async {
    if (ctx.channel case ServerTextChannel channel) {
      final menu = components.get('tickets::trigger');
      
      await channel.send(
        content: 'Please select a ticket',
        components: [
          RowBuilder()
            ..addComponent(menu.build())
        ]);
    }
  }
  @override
  CommandDeclarationBuilder build() {
    return CommandDeclarationBuilder()
      ..setName('ticket')
      ..setDescription('This is a ticket command')
      ..setHandle(handle);
  }
}
Finally, we need to register our component with our client.
void main() async {
  final config = await File('config.yaml').readAsYaml();
  
  final List<Ticket> tickets = config['tickets'].entries
    .map((entry) => Ticket.of(entry.key, entry.value))
    .toList();
  final client = ClientBuilder()
    .build();
  
  client.register(() => MessageTicketComponent(tickets));
  client.register(TicketCommand.new);
  
  await client.init();
}
Extension
Create your own placeholder by implementing the PlaceholderContract interface.
final class MyPlaceholder implements PlaceholderContract {
  final Map<String, dynamic> _values = {};
  @override
  String get identifier => 'my';
  @override
  Map<String, dynamic> get values => _values;
  @override
  String apply(String value) {
    return values.entries.fold(value, (acc, element) {
      final finalValue = switch (element.value) {
        String() => element.value,
        int() => element.value.toString(),
        _ => throw Exception('Invalid type')
      };
      return acc
        .replaceAll('{${element.key}}', finalValue)
        .replaceAll('{{ ${element.key} }}', finalValue);
    });
  }
}
abstract interface class PlaceholderContract {
  Map<String, dynamic> get values;
  String? get identifier;
  String apply(String value);
}