Building a fun Secret Santa Web App using ASP NET Core and Vue (Christmas Hackathon Submission)

Subscribe to my newsletter and never miss my upcoming articles

When I saw that Hashnode was running a Christmas Hackathon competition, I saw it as a great opportunity to build a fun project over the holiday, as well as to showcase some of the techniques that I have blogged about earlier in the year working in a completed web app.

Both my fiancee and I are web developers so we decided to complete an app together. I worked mostly on the backend API, and she worked mostly on the frontend. For those of you who haven't tried writing code with other people, I encourage you to give it a go. It helps so much with problem solving, writing better code, and it's a great skill to have in your professional career. On top of that, I believe that collaboration is in the spirit of this fun Christmas Hackathon!

Our Idea

To give our project a Christmas theme, we decided to make a web app service for a Secret Santa party. For those who have not heard of it before, Secret Santa is a sort of game that is played between groups of friends or work colleagues (not saying that work colleagues can't also be friends 😅). Each person puts their name into a hat, and then everyone takes turns to pull a name out of the hat at random. You must then get a gift for the person whose name you have drawn.

Therefore, our Secret Santa web app allows a Secret Santa party organiser to enter the names and addresses of all of the people in their group. The app then generates a unique link for each person in the group, which will lead to a page telling you the name and the address of the person that you must get a gift for.

The Technology Stack

For this app, we used the following technologies:

  • ASP NET Core Web API
  • SQL Server
  • Vue.js

To ease the development process, I set up separate Docker containers for the web application and for SQL Server. I have made a dedicated blog post on how to achieve this, so please check that out for full details.

Also, we made the decision to serve the Vue.js project from the ASP NET Core web application. Some people prefer to serve the API and the frontend from different servers because it provides better decoupling. However, for a small project like this, I prefer the self contained nature of having the frontend served by the API, especially as it removes the hassle of having to deal with CORS or setting up development SSL certificates. I have detailed how to do this in another blog post (for Nuxt instead of vanilla Vue, but the process is almost identical), so please check that out for full details.

Visual Walkthrough

The theme of the app is that Santa is unwell, so we have to arrange Christmas ourselves! The home page features some cool visual elements, including an animated Santa and countdown clock.

Home Page

We can then click the Get Started button to bring us to a form to create our Secret Santa party. The form asks for a (unique) party name, date and you can add as many participants as you like. The add participant cards ask for a name, email (to anonymously let each person know who they are buying a gift for), and address (so you can mail your gift).

Form Page

Once the party is created, you are brought to a confirmation page. As the organiser, you can have a sneak peak at who has been assigned to who, and send emails to each of the participants to let them know their recipient. Confirmation Page

Pairings Page

Building the Secret Santa App

The complete web application can be found in this GitHub repo. For descriptions and explanations of the code, please read on.

ASP NET Core Web API

When building web APIs with ASP NET Core, I like to follow the Clean Architecture approach, which helps to structure the app in a way that is more maintainable by splitting different concerns into different layers and accessing each layer through interfaces. The benefit of this is that each layer is decoupled and no layer needs to know the details of how any other layer is implemented, just the signature of the interfaces that it needs to call.

There is an excellent GitHub repo by Jason Taylor demonstrating the clean architecture approach for ASP NET Core, that I often use for help and inspiration. Please find it here.

Clean Architecture approach

Using the clean architecture approach, we split the application into 4 separate layers:

  • Web - This is a ASP NET Core Web Application project that actually serves the API and frontend, and contains references to all the other projects
  • Domain - A NET Core library project that contains the core models for the application. This should not depend on any other projects.
  • Application - A NET Core library project that performs all of the functionality of the application. Functionality is split into commands (do something) and queries (get something). Databases, filesystems, and other technicalities are accessed via interfaces defined in this layer
  • Infrastructure - A NET Core library project that contains all of the implementation details for accessing databases, filesystems etc. Contains the concrete classes that implement the interfaces defined in the application layer. This is useful as the actual providers can be changed without having to make any changes to any of the other projects.

The Domain Layer

Since the domain layer sits at the core of the application, we will start with that. This layer contains the models that are used by the application, as well as other core services (that don't depend on any infrastructure). For example, as well as our models, we also have a factory class that assists in creating one of the models, and a UUID generator service that creates the unique identifier used in the link that is given to each participant.

Each of the models used in this application inherit from the Entity abstract class. This is a simple class with an Id property. This is used by our database provider, EF Core, to uniquely identify each instance of a model.

public class Entity
{
    public int Id { get; private set; }
}

The Party class is the model that contains the unique party name (also used to generate the participant links), the party date, and the details of the party members (which is stored as a list of the PartyMember class).

public class Party : Entity
{
    private readonly List<PartyMember> _partyMembers = new List<PartyMember>();

    public Party(string name, DateTime date)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Date = date;
    }

    private Party() { }

    public string Name { get; private set; }
    public DateTime Date { get; private set; }
    public IReadOnlyCollection<PartyMember> PartyMembers => _partyMembers;

    public void AddPartyMember(string name, string email, string address)
    {
        _partyMembers.Add(new PartyMember(name, email, address));
    }
}

public class PartyMember : Entity
{
    public PartyMember(string name, string email, string address)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Email = email ?? throw new ArgumentNullException(nameof(email));
        Address = address ?? throw new ArgumentNullException(nameof(address));
    }

    private PartyMember() { }

    // populated by EF Core
    public int PartyId { get; private set; }

    public string Name { get; private set; }
    public string Email { get; private set; }
    public string Address { get; private set; }
}

Each of these classes has properties with private setters. This is to help maintain data integrity, since it makes each property essentially read-only, and they can only be set through the constructor.

You may also notice the expressions that look like: Name = name ?? throw new ArgumentNullException(nameof(name)); This throws an Exceptions if any of the input parameters are null, which again helps ensure data integrity.

Finally, you will see that the PartyMembers lists is access via a IReadOnlyCollection and backed by a private field. This prevents party members from being added/removed from outside the Party class, and ensures that the AddPartyMember class is the only way to add new party members.

Next, we have the Pairing class, which links a member giving a gift (DonorId) to a member receiving a gift (RecipientId).

public class Pairing : Entity
{
    internal Pairing(int partyId, string identifier, int donorId, int recipientId)
    {
        PartyId = partyId;
        Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier));
        DonorId = donorId;
        RecipientId = recipientId;
    }

    private Pairing() { }

    public int PartyId { get; private set; }
    public string Identifier { get; private set; }
    public int DonorId { get; private set; }
    public int RecipientId { get; private set; }
}

You will notice that it has an internal constructor. This means that this constructor is accessible only from the Domain project and not from any of the other projects. I have done this because I only want the constructor to be called by the PairingFactory class, which is in the same project.

public class PairingFactory : IPairingFactory
{
    private readonly IShortUuidGenerator _uuidGenerator;

    public PairingFactory(IShortUuidGenerator uuidGenerator)
    {
        _uuidGenerator = uuidGenerator ?? throw new ArgumentNullException(nameof(uuidGenerator));
    }

    public Pairing CreatePairing(int partyId, int donorId, int recipientId)
    {
        var uuid = _uuidGenerator.Next();

        return new Pairing(partyId, uuid, donorId, recipientId);
    }
}

The reason that we have a factory class for the Pairing model, and not for the others, is because the creation of the Pairing object is slightly more complex, in that it requires a unique Identifier to be assigned to it (which is used in the link sent to each participant). Therefore, this factory generate a unique identifier using the ShortUuidService and then returns a new Pairing object that uses this identifier.

The ShortUuidGenerator simply creates a new Guid and returns its hashcode. This isn't guaranteed to be globally unique, but should always be unique within one party. The benefit of this is that it is only 8 characters, rather than 32 characters of a full Guid, which is easier for the user to type if need be.

public class ShortUuidGenerator : IShortUuidGenerator
{
    public string Next()
    {
        var guidString = Guid.NewGuid().ToString();

        return guidString.GetHashCode().ToString("x");
    }
}

The Application Layer

The main purpose of the application layer is to coordinate creating the models, saving them to the database, and then read them again at a later time. For that reason, we will start by defining the interface that will interact with the database provider.

Since we know we will be using EF Core as the database provider, we will create an interface that fits with that paradigm. Technically, this isn't pure clean architecture because it will contain a reference to the EF Core package, but I find that this provides a good compromise between decoupling and practicality.

public interface IApplicationDbContext
{
    DbSet<Party> Parties { get; }
    DbSet<PartyMember> PartyMembers { get; }
    DbSet<Pairing> Pairings { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

As mentioned earlier, the application layer consists of commands, which are requests to do something like create or update a model, and queries, which are requests to get data.

In this app, both commands and queries are implemented using the Mediator pattern, using the Mediatr package. I have written about this before here, but essentially each request consists of a request object and a request handler.

The request object is create in the Web layer and will contain the details of the request (i.e. the data you wish to saving or some query parameter). The Mediatr package then works out which handler in the Application layer should be called for this type of request and the code within the handler is executed. This way, each command/query within the application has a single responsibility and is completely decoupled from any other requests.

For example, this application has only one command, the CreatePartyCommand:

public class CreatePartyCommand : IRequest
{
    public string Name { get; set; }
    public DateTime Date { get; set; }
    public IEnumerable<CreatePartyMember> PartyMembers { get; set; }

    public class CreatePartyMember
    {
        public string Name { get; set; }
        public string Email { get; set; }
        public string Address { get; set; }
    }
}

This command contains the data that was entered by the user in the frontend form. It captures the unique name of the party, its date and the details of each of the party members. The CreatePartyCommand is then passed to the CreatePartyCommandHandler, which creates the party and members in the database and assigns each person a recipient (called a Pairing in the code).

public class CreatePartyCommandHandler : IRequestHandler<CreatePartyCommand>
{
    private readonly IApplicationDbContext _dbContext;
    private readonly IPairingFactory _pairingFactory;

    public CreatePartyCommandHandler(IApplicationDbContext dbContext, IPairingFactory pairingFactory)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        _pairingFactory = pairingFactory ?? throw new ArgumentNullException(nameof(pairingFactory));
    }

    public async Task<Unit> Handle(CreatePartyCommand request, CancellationToken cancellationToken)
    {
        var party = new Party(request.Name, request.Date);
        foreach (var partyMember in request.PartyMembers)
            party.AddPartyMember(partyMember.Name, partyMember.Email, partyMember.Address);

        _dbContext.Parties.Add(party);
        await _dbContext.SaveChangesAsync(cancellationToken);

        await CreatePairings(party.PartyMembers, cancellationToken);

        return Unit.Value;
    }

// more methods

As your can see, the CreatePartyCommand is automatically passed as a parameter to the Handle method. We've also injected the interface for the database provider (IApplicationDbContext) into the handler so that we can store the data in the database. In the style of EF Core, we create the party and party members objects, add them to the DbContext, then call SaveChangesAsync which saves the new party to the database. Note that the method returns Unit.Value, which is a quirk of the Mediatr package and is equivalent to returning nothing (i.e. a void method).

I have separated creating the links between the donor and recipient members (Pairing) into a separate method to improve readability:

private async Task CreatePairings(IEnumerable<PartyMember> partyMembers, CancellationToken cancellationToken)
{
    var rng = new Random();
    var pairings = new List<Pairing>();
    var remainingRecipientIds = partyMembers.Select(pm => pm.Id).ToList();

    // create a pairing for each party member
    foreach (var partyMember in partyMembers)
    {
        int recipientId;
        int randomIndex;

        // ensure party member doesnt pick themselves
        do
        {
            randomIndex = rng.Next(remainingRecipientIds.Count);
            recipientId = remainingRecipientIds[randomIndex];
        } while (recipientId == partyMember.Id);

        remainingRecipientIds.RemoveAt(randomIndex);

        var pairing = _pairingFactory.CreatePairing(partyMember.PartyId, partyMember.Id, recipientId);
        pairings.Add(pairing);
    }

    // save pairing to db
    _dbContext.Pairings.AddRange(pairings);
    await _dbContext.SaveChangesAsync(cancellationToken);
}

This is quite a long method, but essentially it does the following. First create a list of all the potential recipient Ids, then loop through each party member. For each party member choose a random recipient Id. The do-while loop checks the the member hasn't been assigned themselves (if they have then a new random Id will be picked until they aren't assigned to themselves). We remove the chosen Id from the list of potential Ids so that no one is picked twice. We then construct the pairing between the member (donor) and the recipient and save each of them to the database.

I mentioned earlier that the party name is unique, but I haven't shown you yet how the system actually checks. This is achieved using a feature of the Mediatr package known as Pipeline Behaviours. Basically I can set up code to be run before or after each handler is run, which is useful for validation, logging, error handling etc. I have created a ValidationBehaviour, which runs validation checks before each request to make sure the command/query is valid.

public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators ?? throw new ArgumentNullException(nameof(validators));
    }

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);

            var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
            var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();

            if (failures.Count != 0) throw new ValidationSecretSantaException(failures);
        }

        return await next();
    }
}

This Pipeline Behaviour checks if a Validator class has been defined for the command/query currently being executed. If it exists then the validation defined in that class is run. The validation classes themselves make use of the FluentValidation package, which I have written about before here.

For example, the CreatePartyCommandValidator, which checks that the party name is unique, is shown below:

public class CreatePartyCommandValidator : RequestValidator<CreatePartyCommand>
{
    public CreatePartyCommandValidator(IApplicationDbContext dbContext) : base(dbContext)
    {
    }

    protected override void ExecuteRules()
    {
        RuleFor(c => c.Name)
            .NotNull()
            .NotEmpty()
            .MustAsync(BeUniqueName);

        RuleFor(c => c.Date)
            .GreaterThanOrEqualTo(DateTime.UtcNow);

        RuleFor(c => c.PartyMembers)
            .NotNull()
            .Must(pm => pm.Count() >= 2);
    }

    private async Task<bool> BeUniqueName(string name, CancellationToken cancellationToken)
    {
        return !await DbContext.Parties
            .Select(p => p.Name)
            .ContainsAsync(name, cancellationToken);
    }
 }

The rule for the party name states that the name must not be null or empty, and must be unique. To check if the name is unique, the BeUniqueName method checks the database to see if any parties already have that name or not. Since this validator is run immediately before the CreatePartyCommandHandler, the handler itself does not need to check for uniqueness as the validator would have thrown an exception before that point if the name was not unique.

The is also a command for sending emails to each member of the party. This relies on a simple IEmailer interface that we define in the Application layer, and later implement in the Infrastructure layer.

public interface IEmailer
{
    Task Send(string to, string subject, string body, bool isHtmlBody = true);
}

public class EmailPartyMembersCommand : IRequest
{
    public string PartyName { get; set; }
}

public class EmailPartyMembersCommandHandler : IRequestHandler<EmailPartyMembersCommand>
{
    private readonly IApplicationDbContext _dbContext;
    private readonly IEmailer _emailer;

    public EmailPartyMembersCommandHandler(IApplicationDbContext dbContext, IEmailer emailer)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        _emailer = emailer ?? throw new ArgumentNullException(nameof(emailer));
    }

    public async Task<Unit> Handle(EmailPartyMembersCommand request, CancellationToken cancellationToken)
    {
        var party = await _dbContext.Parties
            .Where(p => p.Name == request.PartyName)
            .Select(p => new { p.Id, p.Name, p.Date })
            .FirstOrDefaultAsync(cancellationToken);
        var partyMembers = await _dbContext.PartyMembers
            .Where(pm => pm.PartyId == party.Id)
            .Select(pm => new { pm.Id, pm.Name, pm.Address, pm.Email })
            .ToListAsync(cancellationToken);
        var pairings = await _dbContext.Pairings
            .Where(p => p.PartyId == party.Id)
            .Select(p => new { p.DonorId, p.RecipientId })
            .ToListAsync(cancellationToken);

        foreach (var partyMember in partyMembers)
        {
            var pairing = pairings.FirstOrDefault(p => p.DonorId == partyMember.Id);
            var recipient = partyMembers.FirstOrDefault(pm => pm.Id == pairing.RecipientId);

            var body = new StringBuilder();
            body.Append($"<p>You have been added to a Secret Santa party ({party.Name}) taking place on {party.Date:dd/MM/yyyy}</p>");
            body.Append($"<p>Your recipient is: {recipient.Name}</p>");
            body.Append($"<p>Please send your gift to: {recipient.Address}</p>");

            await _emailer.Send(partyMember.Email, "You have been added to a Secret Santa party", body.ToString());
        }

        return Unit.Value;
    }
}

The handler finds the party in the database that matches the unique party name. It then finds all the party members and pairings for that party. Finally an email is constructed and sent via the IEmailer interface to each person, telling them who the should buy a gift for.

The Infrastructure Layer

This is the layer in which we actually implement the interfaces defined in our Application layer.

For example, this is the concrete implementation of IApplicationDbContext:

public class ApplicationDbContext : DbContext, IApplicationDbContext
{
    public ApplicationDbContext(DbContextOptions options) : base(options)
    {
    }

    public DbSet<Party> Parties { get; set; }

    public DbSet<PartyMember> PartyMembers { get; set; }

    public DbSet<Pairing> Pairings { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder
            .ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
    }
}

The key thing here is the OnModelCreating method, which applies the configurations for each of the models. A configuration sets how the C# model is translated to a database and vice versa. For example, the configuration for the Pairing model configures foreign keys to the Party and PartyMember tables, as well as defining column legnth and whether the field is required or not:

public class PairingConfiguration : IEntityTypeConfiguration<Pairing>
{
    public void Configure(EntityTypeBuilder<Pairing> builder)
    {
        builder.Property(p => p.Identifier)
            .HasMaxLength(8)
            .IsRequired();

        builder.HasOne<Party>()
            .WithMany()
            .HasForeignKey(p => p.PartyId);

        builder.HasOne<PartyMember>()
            .WithMany()
            .HasForeignKey(p => p.DonorId)
            .OnDelete(DeleteBehavior.NoAction);

        builder.HasOne<PartyMember>()
            .WithMany()
            .HasForeignKey(p => p.RecipientId)
            .OnDelete(DeleteBehavior.NoAction);
    }
}

For the implementation of the IEmailer interface, I have used the MailKit package, which makes connecting to and sending an email from an SMTP server very straightforward. The email settings values are defined in the appsettings.json file of the Web project so they can be easily configured at any time.

public class Emailer : IEmailer
{
    private readonly ISmtpClient _smtpClient;
    private readonly EmailSettings _emailSettings;

    public Emailer(ISmtpClient smtpClient, IOptions<EmailSettings> emailSettings)
    {
        _smtpClient = smtpClient ?? throw new ArgumentNullException(nameof(smtpClient));
        _emailSettings = emailSettings.Value ?? throw new ArgumentNullException(nameof(emailSettings));
    }

    public async Task Send(string to, string subject, string body, bool isHtmlBody = true)
    {
        var email = CreateEmail(to, subject, body, isHtmlBody);

        await _smtpClient.ConnectAsync(_emailSettings.SmtpHost, _emailSettings.SmtpPort, SecureSocketOptions.StartTls);
        await _smtpClient.AuthenticateAsync(_emailSettings.SmtpUser, _emailSettings.SmtpPassword);
        await _smtpClient.SendAsync(email);
        await _smtpClient.DisconnectAsync(true);
    }

    private MimeMessage CreateEmail(string to, string subject, string body, bool isHtmlBody)
    {
        var email = new MimeMessage();
        email.From.Add(MailboxAddress.Parse(_emailSettings.FromAddress));
        email.To.Add(MailboxAddress.Parse(to));
        email.Subject = subject;
        email.Body = new TextPart(isHtmlBody ? TextFormat.Html : TextFormat.Plain) { Text = body };

        return email;
    }
}

The Web Layer

The main purpose of the Web layer is to map HTTP requests to commands/queries in the Application layer, as well as to serve the Vue.js app. I have covered how to serve a Vue/Nuxt app from an ASP NET Core Web App in another blog post, so I wont cover that here.

Because all of the logic is handled in the Application layer, the Web layer becomes very simple. For example, here is the PartiesController class:

[ApiController]
[Route("api/[controller]")]
public class PartiesController : ControllerBase
{
    private readonly IMediator _mediator;

    public PartiesController(IMediator mediator)
    {
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
    }

    [HttpPost]
    public async Task<Unit> CreateParty(CreatePartyCommand command) => await _mediator.Send(command);

    [HttpPost("EmailPartyMembers")]
    public async Task<Unit> SendEmail(EmailPartyMembersCommand command) => await _mediator.Send(command);
}

The PartiesController has the attribute [Route("api/[controller]")], meaning that it'll accept HTTP requests to api/parties. It has a single method, CreateParty, which responds to HTTP POST requests. The POST request body should have the same data structure as the CreatePartyCommand, which is then sent using the Mediatr package to the correct handler (CreatePartyCommandHandler). Any return value from the handler is then returned by the API (in this case, Unit, which is equivalent to nothing). The SendEmail method also works in a similar way, except that a post request is made to api/parties/emailpartymembers.

Vue Frontend

The Vue frontend was built using Vuetify for the UI components. Therefore, I won't focus too much on the markup, but instead I will focus on the functionality and interacting with the API. If you want to see details of how the markup is put together, you may visit the GitHub repo for this project.

I also have another blog post on building a Vue frontend and a ASP NET Core API, which will be useful to you. Please find it here.

Create Party Form

A particular challenge in making the form was adding the ability to dynamically add/remove participants. I opted to make the card containing the person details a separate component (PersonCard) to help deal with some of the complexity and separate the form a bit.

However, this presented its own problems as the Form page still needed to hold the overall form state, while each PersonCard needed to only know about one person object. Just passing the person object as props in this case is not good enough, since we need to be able to edit the values in the PersonCard and directly mutating props in Vue throws an error.

Luckily, I found an excellent article on how to use the v-model directive with complex objects. That way I could get Vue to automatically handle updating the person data in the Form parent component whenever data was updated within each PersonCard. Below is the relevant code from the PersonCard component:

<template>
      // other markup

      <v-form v-model="isValid" class="valid">
          <v-text-field
            :disabled="!isEditing"
            color="white"
            label="Name"
            :value="value.name"
            @input="onUpdate('name', $event)"
            :rules="[v => !!v || 'Name must not be empty']"
            required
          ></v-text-field>
          <v-text-field
            :disabled="!isEditing"
            color="white"
            label="Address"
            :value="value.address"
            @input="onUpdate('address', $event)"
            :rules="[v => !!v || 'Address must not be empty']"
            required
          ></v-text-field>
          <v-text-field
            class="valid"
            :disabled="!isEditing"
            color="white"
            label="Email"
            :value="value.email"
            @input="onUpdate('email', $event)"
            :rules="[v => !!v || 'Email must not be empty']"
            required
          ></v-text-field>
      </v-form>

      // more markup
</template>

<script>
export default {
  // some data/methods omitted for clarity
  props: ['value'],
  methods: {
    onUpdate(key, value) {
      this.$emit('input', { ...this.value, [key]: value })
    }
  }
};
</script>

Here we pass the whole person object to the PersonCard using the value prop (which is what the v-model directive expects by convention). We set the value of each input using this prop using the value attribute, not the v-model directive. This way we can handle the update ourselves using the @input event. The onUpdate method emits the input event and passes a new object containing the updated value back to the parent using this event. It is important to create a completely new object to prevent the value prop from accidentally being mutated directly. We can then use the v-model directive from within the parent Form page to keep the person state updated:

<template>

           // other markup

      <v-row justify="center" align="center">
        <v-col cols="12" md="6">
          <v-row justify="space-between">
            <v-col class="heading-text" cols="12" md="6">
              <p class="text-wrap">Add Participants</p>
            </v-col>
            <v-col class="text-right mb-4" cols="12" md="6"
              ><v-btn
                @click="onClickAdd"
                color="primary"
                elevation="2"
                fab
                x-large
                ><v-icon>mdi-plus</v-icon>
              </v-btn>
            </v-col>
          </v-row>

          <PersonCard
            v-for="(person, index) in partyMembers"
            :key="index"
            v-model="partyMembers[index]"
            @delete="onDelete(index)"
          />

          <v-btn 
            block 
            color="primary" 
            :disabled="!isValid || !allPersonCardsValid" 
            @click="onClickCreateParty"
            :loading="isSaving"
          >
            Create Party
          </v-btn>
        </v-col>
      </v-row>

          // more markup

</template>

<script>
import PersonCard from '../components/PersonCard';

// some data/methods omitted for clarity
export default {
  data: () => ({
    partyMembers: [{ name: '', address: '', email: '' }],
  }),
  methods: {
    onClickAdd() {
      this.partyMembers.push({ name: '', address: '', email: '' });
    },
    onDelete(index) {
      const partyMembers = this.partyMembers;
      this.partyMembers = [...partyMembers.slice(0, index), ...partyMembers.slice(index + 1)];
    },
    async onClickCreateParty() {
      this.isSaving = true

      try {
        await this.$axios.post('/api/parties', {
          name: this.name,
          date: this.date,
          partyMembers: this.partyMembers,
        })

        this.$router.push({
          name: 'confirmation',
          query: { name: this.name }
        })
      } catch {
        this.hasError = true
      }

      this.isSaving = false
    },
  },
  components: {
    PersonCard
  }
};
</script>

There's quite a lot going on here, so I will break it down. The data object has a property called partyMembers, which contains any array of all of the people that we wish to invite to the Secret Santa Party. For each person in this array, we create a PersonCard component and pass the person data to that component using v-model. Because of how we have set up the PersonCard component (using the value prop and emitting the input event), the value of the person object within the partyMembers array will be updated whenever changes are made to the PersonCard inputs.

The add button creates new PersonCard components by simply pushing a new person object to the partyMembers array. Since Vue is reactive, by adding a new element to this array, the page is automatically re-rendered to show the new PersonCard.

Deleting is a bit more tricky since, to avoid direct mutation, we must construct a new array instead of simply removing the person from the existing array. Luckily there's a neat trick we can do: if we take a slice of all the array items before the index that needs to be deleted, and take a slice of all the item after it too, we can construct a new array with only the element missing (this.partyMembers = [...partyMembers.slice(0, index), ...partyMembers.slice(index + 1)];)

Finally, there is the onClickCreateParty method, which gathers all the information from the form and sends it as a post request to the API backend. Provided that there are no errors, we tell the router to then navigate to the confirmation page, passing the party name as a query parameter to keep track of it (this.$router.push({name: 'confirmation', query: { name: this.name } })).

The Confirmation page contains a button that, when clicked, sends an email to each of the participants telling them who their recipient is. This is achieved simply by posting the party name (which we got from the query string) back to the API endpoint, api/parties/emailpartymembers:

  computed: {
    name() {
      return this.$route.query.name
    }
  },
methods: {
    async sendEmail() {
      this.isSending = true

      try {
        await this.$axios.post('/api/parties/emailpartymembers', { partyName: this.name })
        this.isSending = false
      } catch {
        this.isSending = false
      }
    }
}

For the full markup of each of these components, please visit the GitHub repo.

Conclusion

This has been my submission to the Hashnode Christmas Hackathon. I hope that you have found it both fun and informative. As mentioned, a lot of the technologies I have used in this app I have blogged about in more detail before, so please check out my previous blogs too.

I hope that all of you had a Happy Holiday and I wish you all the best for the New Year!

I post mostly about full stack .NET and Vue web development. To make sure that you don't miss out on any posts, please follow this blog and subscribe to my newsletter. If you found this post helpful, please like it and share it. You can also find me on Twitter.

No Comments Yet