mattmower

mattmower

DemoGen — Create repeatable demo scenarios for SaaS prospect meetings (v0.1.7)

I’m pleased to announce that we’ve released DemoGen v0.1.7, a library for creating repeatable demo scenarios in your Ecto-based SaaS application, under the MIT license.

Push a button and immediately have a demo site, looking exactly the way you want people to see it.

As CEO, I’m often having demoing AgendaScope to prospects, or need to show off some part of the application (e.g. to record a video). But creating and maintaining a demo site is a tedious headache. Resetting it to a known good state can be awkward, or I forget so that the demo doesn’t look right next time I need it. And it is very difficult to vary or experiment with what I am presenting.

What I needed was a demo site, that would always look exactly as I wanted it to, when I wanted it to. That means I need to be able to reset it to known, good, state in 30s or less. I also need to be able evolve my demo as I learn what works best, and to be able to create variations to show specific scenarios.

What I needed was a scriptable demo site. That’s what DemoGen gives me.

The library consists of a parser for a simple demo-script DSL as well as a runner to execute scripts.

Here’s a chunk of my current “TechHarbour” demo script:

# Setup TechHarbour Ltd & its Leadership team

$subdomain = "tharbour"

# Get rid of the demo site if it exists
delete_org {subdomain: $subdomain}

# Same password for all users
$password = "beep-beep"

# Some macros as short-hand for adjusting the clock
macro last_month = alter_clock {M: -1}
macro next_day = alter_clock {D: +1}

# Off we go, demo starts 1 month ago
@last_month

[09:00] add_org {as: org name: "TechHarbour" subdomain: $subdomain}
        set_feature_flag {org: org flag: "show_ai_assistant"}
        set_feature_flag {org: org flag: "show_score_heatmaps"}
        add_group {as: leadership org: org name: "Leadership"}
        set_objectives {
          group: leadership
          o_1: "Achieve product/market fit"
          o_2: "Sustainable revenue model"
          o_3: "Scale engineering operations"
        }

@next_day

# Setup Emily Walker, CEO
[09:00] add_account {as: emily name: "Emily Walker" email: "e.walker@techharbour.com" password: $password}
        join_org {user: emily org: org admin: false}
        join_group {as: m_1 user: emily group: leadership role: "CEO" admin: true}

# Setup James Patel, COO
[09:01] add_account {as: james name: "James Patel" email: "j.patel@techharbour.com" password: $password}
        join_org {user: james org: org admin: false}
        join_group {as: m_2 user: james group: leadership role: "COO" admin: true}

# Setup Sophie Turner, CTO
[09:02] add_account {as: sophie name: "Sophie Turner" email: "s.turner@techharbour.com" password: $password}
        join_org {user: sophie org: org admin: true}
        join_group {as: m_3 user: sophie group: leadership role: "CTO" admin: false}

@next_day

# Setup Oliver Bennett, CMO
[09:00] add_account {as: oliver name: "Oliver Bennett" email: "o.bennett@techharbour.com" password: $password}
        join_org {user: oliver org: org admin: false}
        join_group {as: m_4 user: oliver group: leadership role: "CMO" admin: false}

# Setup Isabella Smith, CFO
[09:01] add_account {as: isabella name: "Isabella Smith" email: "i.smith@techharbour.com" password: $password}
        join_org {user: isabella org: org admin: false}
        join_group {as: m_5 user: isabella group: leadership role: "CFO" admin: false}

# Setup Kai Chen, CPO
#[09:03] add_account {as: kai name: "Kai Chen" email: "k.chen@techharbour.com" password: $password}
#        join_org {user: kai org: org admin: false}
#        join_group {as: m_6 user: kai group: leadership role: "CPO" admin: false}

@next_day

[09:00] add_item {
          as: i_inflation group: leadership creator: isabella
          item_type: "risk" title: "Above average increases in inflation" public: true
          detail: "Rising inflation rates are increasing operational costs while reducing customer spending power and putting pressure on our margins."
        }

[09:01] set_champion {item: i_inflation champion: isabella}
        score_item {item: i_inflation user: isabella impact: 81 timeframe: 75 likelihood: 90 effort: 60}

[09:05] add_item {
          as: i_expenses group: leadership creator: isabella
          item_type: "risk" title: "Expenses up over 20% m-o-m" public: true
          detail: "Expenses have risen above inflation and are cutting into operational margins."
        }

[09:06] set_champion {item: i_expenses champion: isabella}
[09:10] score_item {item: i_expenses user: isabella impact: 85 timeframe: 95 likelihood: 90 effort: 50}

[10:00] tag_item {item: i_expenses tag: "finance"}
        tag_item {item: i_inflation tag: "finance"}
[10:01] set_tag_color {group: leadership tag: "finance" color: "teal"}

@next_day

[10:00] add_item {
          as: i_ai_research group: leadership creator: emily
          item_type: "opportunity" title: "Automating expensive research processes" public: true
          detail: "At the moment the core business is constrained by researchers doing a lot of manual research. Could we use AI to speed up the research process?"
        }

        tag_item {item: i_ai_research tag: "innovation"}
        set_tag_color {group: leadership tag: "innovation" color: "indigo"}
        tag_item {item: i_ai_research tag: "vision"}
        set_tag_color {group: leadership tag: "vision" color: "red"}

[10:01] set_champion {item: i_ai_research champion: emily}

[10:05] score_item {item: i_ai_research user: emily impact: 80 timeframe: 76 likelihood: 75 effort: 60}

[10:15] add_comment {
          as: c_ai_research_1 item: i_ai_research user: emily
          body: "Has anyone here seen an up-to-date review of OpenAI vs Anthropic?"
        }

@next_day

[10:15] score_item {item: i_ai_research user: sophie impact: 92 timeframe: 90 likelihood: 90 effort: 60}
[10:20] score_item {item: i_ai_research user: isabella impact: 45 timeframe: 45 likelihood: 45 effort: 60}
[10:21] score_item {item: i_ai_research user: oliver impact: 79 timeframe: 81 likelihood: 68 effort: 60}
[11:30] score_item {item: i_ai_research user: james impact: 95 timeframe: 98 likelihood: 88 effort: 60}

[11:45] add_comment {
          as: c_ai_research_2 item: i_ai_research user: isabella
          body: "I've no idea what any of this stuff means."
        }

[14:30] add_comment {
          as: c_ai_research_3 item: i_ai_research user: sophie
          body: "We have been experimenting with the APIs, GPT 4-o is better for 'reasoning' tasks while Claude seems to generate a better output."
        }

@next_day

[09:15] score_item {item: i_inflation user: emily impact: 60 timeframe: 50 likelihood: 50 effort: 60}
[09:20] score_item {item: i_inflation user: oliver impact: 71 timeframe: 60 likelihood: 55 effort: 60}
[09:21] score_item {item: i_inflation user: james impact: 63 timeframe: 71 likelihood: 40 effort: 60}
[09:25] score_item {item: i_inflation user: sophie impact: 32 timeframe: 31 likelihood: 25 effort: 60}

…

There’s a lot more, similar stuff.

Some things to note:

DemoGen keeps track of the “current time” and passes it to each command. The alter_clock command is used to change the date/time which we can either do with a macro like @next_day or using the built in sugar-syntax [HH:MM].

Each command has a name like add_org and a map of parameters such as as:, name:, and subomain: that get passed to the command. You can declare variables like $password to pass the same value to multiple commands.

Each command is run by a module that implements the DemoGen.Command behaviour, for example:

defmodule Radar.Demo.Commands.AddOrg do
  import Ecto.Changeset

  use DemoGen, command_name: "add_org"
  alias DemoGen.Command

  alias Radar.Orgs.Org
  alias Radar.Repo

  @impl DemoGen.Command
  def run(args, %{time: t, symbols: symbols} = context) do
    {:string, name} = Map.get(args, :name)
    {:string, subdomain} = Map.get(args, :subdomain)
    {:symbol, as} = Map.get(args, :as)

    with {:ok, %Org{} = org} <- create_org(t, name, subdomain) do
      {:ok, %{context | symbols: Map.put(symbols, as, org)}}
    end
  end

  defp create_org(t, name, subdomain) do
    %Org{}
    |> Org.create_changeset(%{
      "name" => name,
      "subdomain" => subdomain
    })
    |> demo_changeset(%{
      "demo" => true
    })
    |> Command.set_timestamps(t)
    |> Repo.insert()
  end

  defp demo_changeset(org, attrs) do
    org
    |> cast(attrs, [:demo])
  end
end

The run function of each command is passed a map of context which includes the current time t and a map of symbols symbols. The add_org commands creates a new org and binds it to the symbol org. Future commands can lookup this object as org. This eliminates the requirement to query in later commands which can simply get the org binding from the context symbols.

I’ve implemented about a dozen commands that represent key bits of functionality in our app like adding items, scoring, tagging, leaving comments and so on.

To run a script you use the DemoGen.Runner/run_demo/2 function:

def perform(%Oban.Job{args: %{"demo_name" => name, "demo_script" => script}}) do
    with path <- demo_file_path(name) do
      File.write!(path, script)
      Runner.run_demo(path, prefix: command_prefixes(), repo: Radar.Repo)
    end
  end

I use an Oban job to run mine, triggered from an admin page.

Hex.pm: DemoGen
Github: DemoGen

Under the hood this makes use of my parser combinator library Ergo, as well as Ecto, and Timex.

This has already saved me a huge amount of time and made my demos much more predictable. I hope it can be useful for you also.

https://github.com/AgendaScope/DemoGen

Most Liked

Lucassifoni

Lucassifoni

That looks awesome, thank you for sharing !

I lack time to re-implement my “demo mode” now that it exists but it would have benefitted a lot from that kind of tool. I’m forwarding it to a few bootstrapping acquaintances.

mattmower

mattmower

As an aside in my example of creating an Org I set a demo flag to be true. This is a field on the model. I also ensure that the delete_org command will only operate on org records where the demo flag is true to ensure that I cannot inadvertently delete some other organisation on the live server.

Also the commands are executed in a Repo transaction, if anything breaks you’re not left with a half-baked site. My understanding is that, for Postgres at least, this shouldn’t create a problem if you call code that also uses a transaction but I’m not clear about that. It hasn’t come up for us so far as I’m calling into context functions that would be wrapped in a transaction at a higher level.

It occurs to me that I could move the Repo.transaction out of DemoGen and allow the caller to wrap the call to the Runner.run_demo in a transaction themselves.

mattmower

mattmower

I’ve just released v0.1.8 which includes better documentation and the ability to pass relative datetime’s to commands.

My motivation was adding the scheduling of %Meeting{} schema objects to my demo since AgendaScope handles meetings now.

But a meeting is scheduled in the future, so I need a way to specify future dates & times relative to the demos’ concept of “now”. E.g. scheduled_at: demo_now+delta_time.

DemoGen manages “wall clock” time across the demo, passing the “current” time to each command as it runs and offering many ways of manipulating time.

Now this is now possible:

[10:00] add_meeting {
        as: meet_1
        group: leadership
        title: "Weekly Status Update"
        purpose: "Clarify priorities"
        scheduled_at: 🕒+7d-1h
}

Since the ‘demo time’ is 10:00 (which will be the inserted_at and updated_at value for the %Meeting{} schema) the the scheduled_at attribute will be set to 09:00 a week from current demo time.

In the command implementation, these time delta attributes get parsed into a function that can be used to modify a DateTime. For example, you might use it as follows:

{:rel_time, scheduled_fn} = Map.get(args, :scheduled_at)
scheduled_at = scheduled_fn.(t) # => T+7days-1hour
# Now pass `scheduled_at` as an attribute

Where t is the time value passed via the context to the command run function.

DemoGen has saved me hours of time, and severe frustration, by giving me consistently repeatable demo scenarios in our app at the click of a button.

Here is the implementation of add_meeting for our demo:

def run(args, %{time: t, symbols: symbols} = context) do
    group = Command.lookup_symbol(symbols, args, :group)
    creator = Command.lookup_symbol(symbols, args, :creator)

    {:symbol, as} = Map.get(args, :as)
    {:string, title} = Map.get(args, :name)
    {:string, purpose} = Map.get(args, :purpose)
    {:rel_time, scheduled_fn} = Map.get(args, :scheduled_at)
    {:integer, duration_mins} = Map.get(args, :duration)

    with {:ok, %Meeting{} = meeting} =
           add_meeting(t, creator, group, title, purpose, scheduled_fn.(t), duration_mins) do
      {:ok, %{context | symbols: Map.put(symbols, as, meeting)}}
    end
  end

Where Next?

Popular in Announcing Top

jsm
Once is an Ecto type for locally unique 64-bits IDs generated by multiple Elixir nodes. Locally unique IDs make it easier to keep things ...
New
alvises
Hello everyone! :waving_hand: I’m excited to introduce my first Elixir library: YOLO, a library designed to make real-time object detect...
New
Gigitsu
Hi everyone! I’d like to share a small library I’ve recently extracted from a project I’m working on: ginject. ginject provides a minim...
New
metehan
exterm_ai runs a real PTY in Elixir and exposes it in the browser using xterm.js. Previously I shared exterm library without AI this one ...
#ai
New
mindreframer
ElixirProto: Protobuf-Inspired Serialization for Elixir Events I wanted to have an Elixir-native serialization for events, that also supp...
New
type1fool
WebAuthnLiveComponent WebAuthnComponents See this post about renaming the package. Passwordless authentication for Phoenix LiveView app...
New
rms.mrcs
Hi there :waving_hand: Just dropping by to share PhonixLiveState, a lib I’ve just published. It’s still VERY RAW, but already in a reas...
New
murrgelb
Efx is a library to define and test side effects declaratively. It is basically a very focused mocking framework, reducing implementation...
New
anuaralfetahe
Hello Published a new library - ProcessHub! ProcessHub is a library designed to manage process distribution within the Elixir cluster. ...
New
rodloboz
Sifter is a a query filtering library for Ecto. It lets frontend apps send human-readable query strings like: "elixir phoenix status:pu...
New

Other popular topics Top

belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
Tee
can someone please explain to me how Enum.reduce works with maps
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
_russellb
I want to try my hand at web scraping. What tools/libraries do I need to use. I’m hoping to turn this into something professional so don’...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? https://hexdocs.pm/ecto/Ecto.Repo.h...
New

We're in Beta

About us Mission Statement