Introducing LiveTable. A live, editible, observable table.

I've decided to do a bit of enhancement to observables default tables to make them live, responsive, and editable. I've also tried to do in an an 'observable~ish' manner, making things useful and generic and making the actual calls pretty simple. I did this because I didn't like the way I originally implemented my mass balance business model tables. It looked all weird like.

Let's start out seeing what the table and output looks like.

Here's the object. Watch! If you change the table, it'll change this object.

LiveTable

There are three components to making live, responsive, and editable tables:

  1. LiveTable, the function to create the table.
  2. TableInputTypes, which are primatives that can be used to create entry types by column.
  3. LiveTableData, which is how you use the data in the table downstream. The table itself consists of a few components. Note that html is passed as an argument.

The LiveTable function takes a few arguments:

Here's an example.

const table = LiveTable(html, {
  columns,
  initialData,
  description: "Enter cost information for each site of care",
  title: 'Title: hello table', 
  updateStats: (data, container, html) => {
    const { parsed } = data;
    const totalCost = parsed.reduce((sum, row) => 
      sum + (parseFloat(row.totalNumber) * parseFloat(row.averageCost)), 0);
    
    container.innerHTML = '';
    container.appendChild(html`
      <div style="margin-top: 20px; padding: 15px; background: #f8fafc;">
        <h3 style="color: #2563eb; margin: 0 0 10px 0;">Quick Stats</h3>
        <div style="font-size: 1.5em; font-weight: bold;">
          Total Cost: $${totalCost.toLocaleString()}
        </div>
      </div>
    `);
  }
})

view(table)

Cool. This will make a bit more sense when we put all the things together, but let's keep going.

TableInputTypes

There are a few input type primitives that I've coded. They are as follows.

  1. Integer Input Type
  2. Float Input Type
  3. Dropdown Input Type
  4. Text Input Type
  5. Function Input Type
  6. Percentage Input Type

Integer Input Type

The integer input type lets you create a table column that accepts ... check this out... integers. Perfect for when you need to count sheep, votes, or the number of times you've regretted your life choices.

Here's how you include the integer input type in your column definitions:

TableInputTypes.integer({
  width: 50,
  key: 'count',
  label: 'Count',
  min: 0,
  max: 100,
  defaultValue: 10
})

Float Input Type

For those moments when integers are just too...integer-ish. The float input type allows for decimal numbers, giving you the precision you never knew you wanted.

Include the float input type like so:

TableInputTypes.float({
  width: 60,
  key: 'value',
  label: 'Value',
  min: 0.0,
  max: 100.0,
  step: 0.5,
  defaultValue: 0.0
})

Choices, choices. The dropdown input type lets you restrict input to predefined options. It's like democracy, except you can pick good candidates and it works. Oh no, I've just made myself sad.

Here's how you set up a dropdown:

TableInputTypes.dropdown({
  width: 80,
  key: 'type',
  label: 'Type',
  options: [
    { value: 'a', label: 'Type A' },
    { value: 'b', label: 'Type B' },
    { value: 'c', label: 'Type C' }
  ],
  defaultValue: 'a'
})

Text Input Type

For when you want to let users type whatever they want. Risky move, but sometimes necessary. The text input type accepts any string, including those emoji-filled responses from more expressive users.

Include a text input like this:

TableInputTypes.text({
  width: 150,
  key: 'description',
  label: 'Description',
  placeholder: 'Enter details here...',
  defaultValue: '',
  parser: null
})

Function Input Type

Let users input function names. Because letting users execute code indirectly is totally safe and not at all terrifying.

Here's how you set up a function input:

TableInputTypes.function({
  width: 100,
  key: 'callback',
  label: 'Callback Function',
  validator: (funcName) => {
    if (typeof window[funcName] !== 'function') {
      alert('Function does not exist!');
    }
  }
})

Percentage Input Type

When you need numbers between 0 and 100. Perfect for grades, battery levels, or the chance of you understanding this code with Claude or GPT mommy.

Include a percentage input like so:

TableInputTypes.percentage({
  width: 70,
  key: 'completion',
  label: 'Completion (%)',
  defaultValue: '0'
})

Bonus: Custom Parsers

Wait, there's more! You can add a parser function to any input type if you feel like being fancy. Useful for when users input JSON strings, and you want to parse them into objects because dealing with strings is so last year. This is good if you want to input dictionaries with parameters. Those parameters in that dict will then be parsed correctly in the parsedData() output.

TableInputTypes.text({
  width: 200,
  key: 'parameters',
  label: 'Parameters',
  parser: (value) => {
    try {
      return JSON.parse(value);
    } catch {
      return value;
    }
  }
})

LiveTableData

Due to how reactivity works in observable or javascript (I'm not exactly sure), getting objects to update requires generator functions. Admittidly, I have no idea really how these things are supposed to work and I only mamaged to stumble through it with a lot of LLM assistance.

However, you don't have to worry about it, because I've buried it all in the code. Note that in framework, you need to seperate the generate from the viewing to make it work properly.

const data = LiveTableData(table)
view(data)

An example

You've made it this far. Awesome sauce. Here's an example pulled directly from the mass balance business case page.

// Define care site options
const careSiteOptions = [
  { value: "ABL", label: "Ambulance" },
  { value: "ASC", label: "Ambulatory Surgical Center" },
  { value: "CCF", label: "Custodial Care Facility" },
  { value: "ETF", label: "ESRD Treatment Facility" },
  { value: "EMR", label: "Emergency Room" },
  { value: "HOM", label: "Home" },
  { value: "HOS", label: "Hospice" },
  { value: "ILB", label: "Independent Laboratory" },
  { value: "IPH", label: "Inpatient Hospital" },
  { value: "IPF", label: "Inpatient Psychiatric Facility" },
  { value: "NUF", label: "Nursing Facility" },
  { value: "OFF", label: "Office" },
  { value: "OTH", label: "Other" },
  { value: "OPH", label: "Outpatient Hospital" },
  { value: "RHC", label: "Public/Rural Health Clinic" },
  { value: "SNF", label: "Skilled Nursing Facility" },
  { value: "UCF", label: "Urgent Care Facility" }
];

// Define distribution options
const distributionOptions = [
  { value: "uniform", label: "Uniform" },
  { value: "exponential", label: "Exponential" },
  { value: "poisson", label: "Poisson" },
  { value: "binomial", label: "Binomial" },
  { value: "beta", label: "Beta" },
  { value: "gamma", label: "Gamma" },
  { value: "chisquare", label: "Chi-Square" },
  { value: "bernoulli", label: "Bernoulli" },
  { value: "geometric", label: "Geometric" },
  { value: "pareto", label: "Pareto" },
  { value: "lognormal", label: "Log-Normal" },
  { value: "weibull", label: "Weibull" },
  { value: "cauchy", label: "Cauchy" },
  { value: "multinomial", label: "Multinomial" }
];

// Define initial data
const initialData = [
  {
    siteOfCare: 'asd',
    totalNumber: '1000',
    averageCost: '150.00',
    distribution: 'lognormal',
    parameters: '{"sigma": 0.5, "scale": 150}',
    immoveableFraction: '20'
  },
  {
    siteOfCare: 'IPH',
    totalNumber: '500',
    averageCost: '2500.00',
    distribution: 'lognormal',
    parameters: '{"sigma": 0.7, "scale": 2500}',
    immoveableFraction: '80'
  },
  {
    siteOfCare: 'ASC',
    totalNumber: '750',
    averageCost: '1200.00',
    distribution: 'lognormal',
    parameters: '{"sigma": 0.6, "scale": 1200}',
    immoveableFraction: '50'
  },
  {
    siteOfCare: 'EMR',
    totalNumber: '250',
    averageCost: '800.00',
    distribution: 'lognormal',
    parameters: '{"sigma": 0.8, "scale": 800}',
    immoveableFraction: '90'
  }
];

// Define columns
const columns = [
  TableInputTypes.dropdown({
    width: 200,
    key: 'siteOfCare',
    label: 'Site of Care',
    options: careSiteOptions,
    defaultValue: 'OFF'
  }),
  TableInputTypes.integer({
    width: 100,
    key: 'totalNumber',
    label: 'Total Number (#)',
    min: 0
  }),
  TableInputTypes.float({
    width: 120,
    key: 'averageCost',
    label: 'Average Cost ($)',
    min: 0,
    step: 0.01
  }),
  TableInputTypes.dropdown({
    width: 150,
    key: 'distribution',
    label: 'Distribution',
    options: [
      { value: "lognormal", label: "Log-Normal" },
      { value: "gamma", label: "Gamma" },
      { value: "uniform", label: "Uniform" }
    ],
    defaultValue: 'lognormal'
  }),
  TableInputTypes.text({
    width: 200,
    key: 'parameters',
    label: 'Parameters',
    parser: (value) => {
      try {
        return JSON.parse(value);
      } catch {
        return value;
      }
    },
    placeholder: 'Enter as JSON',
    defaultValue: '{"sigma": 1, "scale": 1}'
  }),
  TableInputTypes.percentage({
    width: 100,
    key: 'immoveableFraction',
    label: 'Immoveable (%)',
    defaultValue: '0'
  })
];

Let's instantiate the table.

const tableWithCustomStats = LiveTable(html, {
  columns,
  initialData,
  description: "Enter cost information for each site of care",
  title: 'Title: hello table', 
  updateStats: (data, container, html) => {
    const { parsed } = data;
    const totalCost = parsed.reduce((sum, row) => 
      sum + (parseFloat(row.totalNumber) * parseFloat(row.averageCost)), 0);
    
    container.innerHTML = '';
    container.appendChild(html`
      <div style="margin-top: 20px; padding: 15px; background: #f8fafc;">
        <h3 style="color: #2563eb; margin: 0 0 10px 0;">Quick Stats</h3>
        <div style="font-size: 1.5em; font-weight: bold;">
          Total Cost: $${totalCost.toLocaleString()}
        </div>
      </div>
    `);
  }
})

view(tableWithCustomStats)

And now let's view the live data. Edit the table. See! See!!! It CHANGES OMG THIS TOOK FOREVER TO FIGURE OUT OMG.


const livetabledata = LiveTableData(tableWithCustomStats)
view(livetabledata)