writer / writer-framework

No-code in the front, Python in the back. An open-source framework for creating data apps.
https://dev.writer.com/framework/introduction
Apache License 2.0
1.31k stars 77 forks source link

FR: Support of the AG Grid for dataframes #149

Closed thondeboer closed 9 months ago

thondeboer commented 1 year ago

Any idea if you want to support the AG Grid system for dataframes? I would like some more control over the dataframes in ss and was looking at AG Grid which look like a great dataframe system. I could consider looking at making a custom component, but not very well versed in JS so would be an uphill battle and perhaps you are already considering it your selve?

Or perhaps just a little more control (such as data representation and server-side sorting etc.? Loving the ease of programming in ss after my struggles with DASH, but some more components woudl be great...

FabienArcellier commented 1 year ago

Hi, I have tried to implement ag grid support as custom component. It was working and compatible with streamsync. There is still work to perform to make it work with backend. Could you share some examples of table you want to perform ?

I don't know yet when I will have time working more on this component.

https://github.com/streamsync-cloud/streamsync/assets/159559/c9fac8d6-099c-46d8-b45e-12eb314caa43

<template>
  <div ref="rootEl" style="height: 150px; width: 100%" class="ag-theme-alpine">
  </div>
</template>

<script lang="ts">

import 'ag-grid-community/styles//ag-grid.css';
import 'ag-grid-community/styles//ag-theme-alpine.css';

const defaultSpec = {
  columnDefs: [
    {headerName: 'Make', field: 'make'},
    {headerName: 'Model', field: 'model'},
    {headerName: 'Price', field: 'price'}
  ],
  rowData: [
    {make: 'Toyota', model: 'Celica', price: 35000},
    {make: 'Ford', model: 'Mondeo', price: 32000},
    {make: 'Porsche', model: 'Boxster', price: 7200}
  ]
};

import {FieldType} from "../streamsyncTypes";
export default {

  streamsync: {
    name: "Ag Grid",
    description: "",
    category: "Content",

    // Fields will be editable via Streamsync Builder

    fields: {
      spec: {
        name: "Grid specification",
        default: JSON.stringify(defaultSpec, null, 2),
        desc: "Ag Grid specification",
        type: FieldType.Object,
      },
    },
  },
};

</script>
<script setup lang="ts">
import { inject, onMounted, ref, Ref, watch} from "vue";
import injectionKeys from "../injectionKeys";

/*
The values for the fields defined earlier in the custom option
will be available using the evaluatedFields injection symbol.
*/

const rootEl: Ref<HTMLElement> = ref(null);
const fields = inject(injectionKeys.evaluatedFields);
let aggrid: any = null;

const renderGrid = async () => {
  if (import.meta.env.SSR) return;
  if (!fields.spec.value || !rootEl.value) return;
  const {Grid} = await import('ag-grid-community');
  if (aggrid === null) {
    aggrid = new Grid(rootEl.value, fields.spec.value);
  } else {
    aggrid.gridOptions.api.setRowData(fields.spec.value.rowData);
  }
};

watch(() => fields.spec.value, (spec) => {
    if (!spec) return;
    renderGrid();
});

onMounted(() => {
  renderGrid();
  if (!rootEl.value) return;
  // new ResizeObserver(renderGrid).observe(rootEl.value, {
  //   box: "border-box",
  // });
});

</script>

<style scoped>
</style>
thondeboer commented 1 year ago

awesome, that looks pretty doable...I just have a couple of repeaters with couple of dataframes of perhaps millions of rows, so need serverside control or sorting and search etc.

FabienArcellier commented 1 year ago

The main challenge is to implement an encapsulation layer of SSRM (server side rendering model) from ag-grid on the frontend size to integrate it into the streamsync exchange protocol. Then we have to create the field to map backend function like ag-grid fetch. This function will expose the content of the SSRM request (sort, filter, ...) in the function context.

The first version will probably use client size row model only.

thondeboer commented 1 year ago

While I am looking at AG Grid, i got inspired and created a Serverside version of the dataframe, using the original as the source. I added a few extra things that I wanted as well: 1) Server side paging through event 1) Server side sorting through event 1) Server side searching through event 1) Row-click through event gets row data as payload 1) Download the complete dataframe or filtered one through event 1) Mouse over on all cells to see full text 1) Automatic scientific notation on numerical cellls

https://github.com/streamsync-cloud/streamsync/assets/3422829/2afe4a64-aa38-40ed-b107-2a1a5b31b472

Here's the VUE file for those intererested. It's a little rough since I am not quite versed in TS, but it works :) Happy to do a PR if you want to add it to the custom components, but maybe we should setup some sort of repository for community developed components that you can easily install and use...

<template>
    <div class="ServerDataframe" ref="rootEl">
        <div class="tools" ref="toolsEl">
            <div class="search" v-if="fields.enableSearch.value === 'yes'">
                <i class="ri-search-line"></i>
                <input type="text" v-on:change="handleServerSearchChange" placeholder="Search..." />
            </div>
            <button class="download" v-on:click="emitDownloadEvent" v-if="fields.enableDownload.value === 'yes'">
                <i class="ri-download-2-line"></i>
            </button>
        </div>
        <div class="gridContainer" v-on:scroll="handleScroll" ref="gridContainerEl">
            <div class="grid" :style="gridStyle" :class="{
                scrolled: rowOffset > 0,
                wrapText: fields.wrapText.value === 'yes',
            }">
                <div v-if="isIndexShown" data-streamsync-grid-col="0" class="cell headerCell indexCell"
                    :style="gridHeadStyle">
                    <div class="name"></div>
                    <div class="widthAdjuster"></div>
                </div>
                <div v-for="(columnName, columnPosition) in shownColumnNames" :data-streamsync-grid-col="columnPosition + (isIndexShown ? 1 : 0)
                    " :key="columnName" class="cell headerCell" :style="gridHeadStyle"
                    v-on:click="handleSetOrder($event, columnName)">
                    <div class="name">
                        {{ columnName }}
                    </div>
                    <div class="icon" :style="{
                        visibility:
                            orderSetting?.columnName == columnName
                                ? 'visible'
                                : 'hidden',
                    }">
                        <i class="ri-arrow-down-line" v-show="!orderSetting?.descending"></i>
                        <i class="ri-arrow-up-line" v-show="orderSetting?.descending"></i>
                    </div>
                    <div class="widthAdjuster"></div>
                </div>
                <template v-for="(row, rowNumber) in slicedTable?.data" :key="rowNumber">
                    <div v-if="isIndexShown" class="cell indexCell" v-on:click="emitRowClickEvent(row)">
                        <template v-if="tableIndex.length == 0">
                            {{ slicedTable.indices[rowNumber] }}
                        </template>
                        <template v-else>
                            {{ indexColumnNames.map((c) => row[c]).join(", ") }}
                        </template>
                    </div>
                    <div v-for="(columnName, columnPosition) in shownColumnNames" :key="columnName"
                        :class="['cell', isNumeric(row[columnName]) ? 'numeric' : '']" :title="row[columnName]"
                        v-on:click="emitRowClickEvent(row)">
                        <!--Making a column called "index" special and not making it numerical-->
                        <template v-if="columnName !== 'index' && isNumeric(row[columnName])">
                            {{ formatNumber(row[columnName]) }}
                        </template>
                        <template v-else>
                            {{ row[columnName] }}
                        </template>
                    </div>
                </template>
            </div>
            <div class="endpoint" :style="endpointStyle"></div>
        </div>
        <div class="pagingtools" ref="bottomtoolsEl" v-if="fields.enablePaging.value === 'yes'">
            <div class="left-container">
                <div class="rowCountLabel" ref="rowCountLabelEl">
                    <label class="mainLabel">Rows: {{ fields.totalRowCount.value }}</label>
                </div>
            </div>
            <div class="right-container">
                <div><label class="mainLabel">Rows per page:</label></div>
                <div class="CoreDropdownInput" ref="pageSize">
                    <div class="selectContainer">
                        <select :value="formValue"
                            v-on:input="($event) => handleInput(($event.target as HTMLInputElement).value, 'paging')">
                            <option value="5">5</option>
                            <option value="10" selected>10</option>
                            <option value="20">20</option>
                            <option value="50">50</option>
                            <option value="100">100</option>
                            <option value="1000">1000</option>
                        </select>
                    </div>
                </div>
                <button class="download" v-on:click="paging('page', 'left')">
                    <i class="ri-arrow-left-double-line"></i>
                </button>
                <button class="download" v-on:click="paging('row', 'left')">
                    <i class="ri-arrow-left-s-line"></i>
                </button>
                <button class="download" v-on:click="paging('row', 'right')">
                    <i class="ri-arrow-right-s-line"></i>
                </button>
                <button class="download" v-on:click="paging('page', 'right')">
                    <i class="ri-arrow-right-double-line"></i>
                </button>
            </div>
        </div>

    </div>
</template>

<script lang="ts">
import { Ref, computed, inject, ref } from "vue";
import { FieldCategory, FieldControl, FieldType, StreamsyncComponentDefinition } from "../streamsyncTypes";
import {
    cssClasses,
    primaryTextColor,
    secondaryTextColor,
    separatorColor,
} from "../renderer/sharedStyleFields";
import { onMounted } from "vue";
import { watch } from "vue";
import { nextTick } from "vue";
import { ComputedRef } from "vue";
import { onUnmounted } from "vue";
const description = "A component to display Pandas DataFrames for serverside sorting, searching.";
const defaultDataframe = `data:application/vnd.apache.arrow.file;base64,QVJST1cxAAD/////iAMAABAAAAAAAAoADgAGAAUACAAKAAAAAAEEABAAAAAAAAoADAAAAAQACAAKAAAAlAIAAAQAAAABAAAADAAAAAgADAAEAAgACAAAAGwCAAAEAAAAXwIAAHsiaW5kZXhfY29sdW1ucyI6IFsiX19pbmRleF9sZXZlbF8wX18iXSwgImNvbHVtbl9pbmRleGVzIjogW3sibmFtZSI6IG51bGwsICJmaWVsZF9uYW1lIjogbnVsbCwgInBhbmRhc190eXBlIjogInVuaWNvZGUiLCAibnVtcHlfdHlwZSI6ICJvYmplY3QiLCAibWV0YWRhdGEiOiB7ImVuY29kaW5nIjogIlVURi04In19XSwgImNvbHVtbnMiOiBbeyJuYW1lIjogImNvbF9hIiwgImZpZWxkX25hbWUiOiAiY29sX2EiLCAicGFuZGFzX3R5cGUiOiAiaW50NjQiLCAibnVtcHlfdHlwZSI6ICJpbnQ2NCIsICJtZXRhZGF0YSI6IG51bGx9LCB7Im5hbWUiOiAiY29sX2IiLCAiZmllbGRfbmFtZSI6ICJjb2xfYiIsICJwYW5kYXNfdHlwZSI6ICJpbnQ2NCIsICJudW1weV90eXBlIjogImludDY0IiwgIm1ldGFkYXRhIjogbnVsbH0sIHsibmFtZSI6IG51bGwsICJmaWVsZF9uYW1lIjogIl9faW5kZXhfbGV2ZWxfMF9fIiwgInBhbmRhc190eXBlIjogImludDY0IiwgIm51bXB5X3R5cGUiOiAiaW50NjQiLCAibWV0YWRhdGEiOiBudWxsfV0sICJjcmVhdG9yIjogeyJsaWJyYXJ5IjogInB5YXJyb3ciLCAidmVyc2lvbiI6ICIxMi4wLjAifSwgInBhbmRhc192ZXJzaW9uIjogIjEuNS4zIn0ABgAAAHBhbmRhcwAAAwAAAIgAAABEAAAABAAAAJT///8AAAECEAAAACQAAAAEAAAAAAAAABEAAABfX2luZGV4X2xldmVsXzBfXwAAAJD///8AAAABQAAAAND///8AAAECEAAAABgAAAAEAAAAAAAAAAUAAABjb2xfYgAAAMD///8AAAABQAAAABAAFAAIAAYABwAMAAAAEAAQAAAAAAABAhAAAAAgAAAABAAAAAAAAAAFAAAAY29sX2EAAAAIAAwACAAHAAgAAAAAAAABQAAAAAAAAAD/////6AAAABQAAAAAAAAADAAWAAYABQAIAAwADAAAAAADBAAYAAAAMAAAAAAAAAAAAAoAGAAMAAQACAAKAAAAfAAAABAAAAACAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAABAAAAAAAAAAAAAAAAMAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAAAAAAwAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAD/////AAAAABAAAAAMABQABgAIAAwAEAAMAAAAAAAEADwAAAAoAAAABAAAAAEAAACYAwAAAAAAAPAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAACgAMAAAABAAIAAoAAACUAgAABAAAAAEAAAAMAAAACAAMAAQACAAIAAAAbAIAAAQAAABfAgAAeyJpbmRleF9jb2x1bW5zIjogWyJfX2luZGV4X2xldmVsXzBfXyJdLCAiY29sdW1uX2luZGV4ZXMiOiBbeyJuYW1lIjogbnVsbCwgImZpZWxkX25hbWUiOiBudWxsLCAicGFuZGFzX3R5cGUiOiAidW5pY29kZSIsICJudW1weV90eXBlIjogIm9iamVjdCIsICJtZXRhZGF0YSI6IHsiZW5jb2RpbmciOiAiVVRGLTgifX1dLCAiY29sdW1ucyI6IFt7Im5hbWUiOiAiY29sX2EiLCAiZmllbGRfbmFtZSI6ICJjb2xfYSIsICJwYW5kYXNfdHlwZSI6ICJpbnQ2NCIsICJudW1weV90eXBlIjogImludDY0IiwgIm1ldGFkYXRhIjogbnVsbH0sIHsibmFtZSI6ICJjb2xfYiIsICJmaWVsZF9uYW1lIjogImNvbF9iIiwgInBhbmRhc190eXBlIjogImludDY0IiwgIm51bXB5X3R5cGUiOiAiaW50NjQiLCAibWV0YWRhdGEiOiBudWxsfSwgeyJuYW1lIjogbnVsbCwgImZpZWxkX25hbWUiOiAiX19pbmRleF9sZXZlbF8wX18iLCAicGFuZGFzX3R5cGUiOiAiaW50NjQiLCAibnVtcHlfdHlwZSI6ICJpbnQ2NCIsICJtZXRhZGF0YSI6IG51bGx9XSwgImNyZWF0b3IiOiB7ImxpYnJhcnkiOiAicHlhcnJvdyIsICJ2ZXJzaW9uIjogIjEyLjAuMCJ9LCAicGFuZGFzX3ZlcnNpb24iOiAiMS41LjMifQAGAAAAcGFuZGFzAAADAAAAiAAAAEQAAAAEAAAAlP///wAAAQIQAAAAJAAAAAQAAAAAAAAAEQAAAF9faW5kZXhfbGV2ZWxfMF9fAAAAkP///wAAAAFAAAAA0P///wAAAQIQAAAAGAAAAAQAAAAAAAAABQAAAGNvbF9iAAAAwP///wAAAAFAAAAAEAAUAAgABgAHAAwAAAAQABAAAAAAAAECEAAAACAAAAAEAAAAAAAAAAUAAABjb2xfYQAAAAgADAAIAAcACAAAAAAAAAFAAAAAsAMAAEFSUk9XMQ==`;

const clickRowHandlerStub = `
def handle_row_click(state, payload):
    state["clicked_row"] = payload`;

const clickColumnSortHandlerStub = `
def handle_column_sort(state, payload):
    state["column_sort"] = payload`;

const pagingHandlerStub = `
def handle_paging(state, payload):
    # Payload is either string (pageSize) or object (paging)
    # {
    # "row_or_page": row_or_page,
    # "direction": direction,
    # "pageSize": pageSize.value
    #}
    # row_or_page: "row" or "page"
    # direction: "left" or "right"
    # pageSize: number of rows selected from dropdown
    p = payload
    if type(p) == str:
        # Do something with pageSize
    elif type(p) == dict:
        # Do something with paging`;

const searchHandlerStub = `
def handle_search(state, payload):
    search_text = payload['search_text']`;

const downloadHandlerStub = `
def handle_download(state, payload):
    data = ss.pack_file("assets/story.txt", "text/plain")
    file_name = "thestory.txt"
    state.file_download(data, file_name)`;

const streamsync: StreamsyncComponentDefinition = {
    name: "Server DataFrame",
    description,
    category: "Content",
    fields: {
        dataframe: {
            name: "Data",
            desc: "Must be a state reference to a Pandas dataframe or PyArrow table. Alternatively, a URL for an Arrow IPC file.",
            type: FieldType.Text,
            default: defaultDataframe,
        },
        showIndex: {
            name: "Show index",
            desc: "Shows the dataframe's index. If an Arrow table is used, shows the zero-based integer index.",
            type: FieldType.Text,
            default: "yes",
            options: {
                yes: "yes",
                no: "no",
            },
        },
        enableSearch: {
            name: "Enable search",
            type: FieldType.Text,
            default: "no",
            options: {
                yes: "yes",
                no: "no",
            },
        },
        enableDownload: {
            name: "Enable download",
            desc: "Allows the user to download the data as CSV.",
            type: FieldType.Text,
            default: "no",
            options: {
                yes: "yes",
                no: "no",
            },
        },
        displayRowCount: {
            name: "Display row count",
            desc: "Specifies how many rows to show simultaneously.",
            type: FieldType.Number,
            category: FieldCategory.Style,
            default: "10",
        },
        totalRowCount: {
            name: "Display total row count",
            desc: "Shows total number of rows in the server-side dataframe. Meant to be bound to state variable that is updated when the dataframe is updated.",
            type: FieldType.Text,
            category: FieldCategory.Style,
            default: "10/100",
        },
        enablePaging: {
            name: "Enable paging",
            desc: "Enable paging with row and page buttons.",
            type: FieldType.Text,
            default: "yes",
            options: {
                yes: "yes",
                no: "no",
            },
        },
        wrapText: {
            name: "Wrap text",
            type: FieldType.Text,
            category: FieldCategory.Style,
            desc: "Not wrapping text allows for an uniform grid, but may be inconvenient if your data contains longer text fields.",
            options: {
                yes: "yes",
                no: "no",
            },
            default: "no",
        },
        primaryTextColor,
        secondaryTextColor,
        separatorColor,
        dataframeBackgroundColor: {
            name: "Background",
            type: FieldType.Color,
            category: FieldCategory.Style,
            default: "#ffffff",
            applyStyleVariable: true,
        },
        dataframeHeaderRowBackgroundColor: {
            name: "Header row background",
            type: FieldType.Color,
            category: FieldCategory.Style,
            default: "#f0f0f0",
            applyStyleVariable: true,
        },
        fontStyle: {
            name: "Font style",
            type: FieldType.Text,
            category: FieldCategory.Style,
            options: {
                normal: "normal",
                monospace: "monospace",
            },
            default: "normal",
        },
        cssClasses,
    },

    events: {
        "row-click": {
            desc: "Emitted when a row is clicked.",
            stub: clickRowHandlerStub
        },
        "sort-column": {
            desc: "Emitted when a column is sorted.",
            stub: clickColumnSortHandlerStub
        },
        "paging": {
            desc: "Emitted when one of the paging buttons is clicked.",
            stub: pagingHandlerStub
        },
        "search": {
            desc: "Emitted when search is used.",
            stub: searchHandlerStub
        },
        "download": {
            desc: "Emitted when download button is clicked.",
            stub: downloadHandlerStub
        }

    },
}

export default {
    streamsync
};

</script>
<script setup lang="ts">
import injectionKeys from "../injectionKeys";
import { useFormValueBroker } from "../renderer/useFormValueBroker";
import * as aq from "arquero";
import { tableFromIPC, Table } from "apache-arrow";

const rootEl: Ref<HTMLElement> = ref(null); // Root element is used to fire events
const ss = inject(injectionKeys.core);
const componentId = inject(injectionKeys.componentId);

const { formValue, handleInput } = useFormValueBroker(ss, componentId, rootEl);
/**
 * Only a certain amount of rows is rendered at a time (MAX_ROWS_RENDERED),
 * to prevent filling the DOM with unnecessary rows.
 */
const ROW_HEIGHT_PX = 36; // Must match CSS
const MIN_COLUMN_WIDTH_PX = 80;
const MAX_COLUMN_AUTO_WIDTH_PX = 300;
const UNNAMED_INDEX_COLUMN_PATTERN = /^__index_level_[0-9]+__$/;

/**
 * Settings for column sorting order.
 * @typedef {Object} OrderSetting
 * @property {string} columnName - The name of the column.
 * @property {boolean} descending - Whether the sort is descending.
 */
type OrderSetting = {
    columnName: string;
    descending: boolean;
};

const fields = inject(injectionKeys.evaluatedFields);
const toolsEl: Ref<HTMLElement> = ref();
const gridContainerEl: Ref<HTMLElement> = ref();
let baseTable: aq.internal.ColumnTable = null;
const table: Ref<aq.internal.ColumnTable> = ref(null);
const tableIndex = ref([]);
const isIndexShown = computed(() => fields.showIndex.value == "yes");
const orderSetting: Ref<OrderSetting> = ref(null);
const relativePosition: Ref<number> = ref(0);
const columnWidths: Ref<number[]> = ref([]);
let columnBeingWidthAdjusted: number = null;

formValue.value = fields.displayRowCount.value;  // Initialize formValue with default value

/**
 * Computed value representing the names of the columns in the table.
 * @type {ComputedRef<string[]>}
 */
const columnNames: ComputedRef<string[]> = computed(() => {
    if (!table.value) {
        return [];
    }
    return table.value?.columnNames();
});
/**
 * Computed value representing the names of the index columns.
 * @type {ComputedRef<string[]>}
 */
const indexColumnNames = computed(() =>
    columnNames.value.filter((c) => tableIndex.value.includes(c)),
);

/**
 * Computes an array of shown column names based on various conditions.
 * - Filters out columns that are both index columns (`tableIndex.value` includes them) and unnamed (matching `UNNAMED_INDEX_COLUMN_PATTERN`).
 *
 * @function
 * @returns {Array<string>} The array of column names that should be shown.
 */
const shownColumnNames = computed(() => {
    const cols = columnNames.value.filter((c) => {
        const isIndex = tableIndex.value.includes(c);
        const isUnnamed = UNNAMED_INDEX_COLUMN_PATTERN.test(c);
        return !(isIndex && isUnnamed);
    });
    return cols;
});

const columnCount = computed(
    () => (isIndexShown.value ? 1 : 0) + shownColumnNames.value.length,
);
const rowCount = computed(() => table.value?.numRows() ?? 0);
const displayRowCount = computed(() =>
    formValue.value !== null ? Math.min(Number(formValue.value), rowCount.value) : Math.min(fields.displayRowCount.value, rowCount.value),

);

/**
 * Computes the row offset based on various conditions.
 * - If `fields.wrapText.value` is "yes", the `maxOffset` is set to `rowCount.value - 1`.
 * - Otherwise, `maxOffset` is set to `rowCount.value - displayRowCount.value`.
 * The function then calculates the new offset based on `relativePosition.value` and returns it.
 *
 * @function
 * @returns {number} The computed row offset.
 */
const rowOffset = computed(() => {
    let maxOffset: number;
    if (fields.wrapText.value == "yes") {
        maxOffset = rowCount.value - 1;
    } else {
        maxOffset = rowCount.value - displayRowCount.value;
    }
    const newOffset = Math.min(
        Math.floor(relativePosition.value * maxOffset),
        maxOffset,
    );
    return newOffset;
});

/**
 * Computed value to get a sliced portion of the table based on row offset and display row count.
 * @type {ComputedRef<null | {data: any, indices: any}>}
 */
const slicedTable = computed(() => {
    if (!table.value) return null;
    const data = table.value.objects({
        offset: rowOffset.value,
        limit: displayRowCount.value,
    });
    const indices = table.value
        .indices()
        .slice(rowOffset.value, rowOffset.value + displayRowCount.value);
    return {
        data,
        indices,
    };
});

/**
 * Computed value to determine the style of the grid head.
 * @type {ComputedRef<Object>}
 */
const gridHeadStyle = computed(() => {
    return {
        "background-color": fields.dataframeHeaderRowBackgroundColor.value,
    };
});

/**
 * Computed value to determine the overall style of the grid.
 * @type {ComputedRef<Object>}
 */
const gridStyle = computed(() => {
    const fontStyle = fields.fontStyle.value;
    let templateColumns: string, maxHeight: number;

    if (columnWidths.value.length == 0) {
        templateColumns = `repeat(${columnCount.value}, minmax(min-content, 1fr))`;
    } else {
        templateColumns = columnWidths.value
            .map((cw) => `${Math.max(cw, MIN_COLUMN_WIDTH_PX)}px`)
            .join(" ");
    }

    if (fields.wrapText.value == "yes") {
        maxHeight = (displayRowCount.value + 1) * ROW_HEIGHT_PX;
    }

    return {
        "min-height": `${ROW_HEIGHT_PX * (1 + fields.displayRowCount.value)}px`,
        "max-height": maxHeight ? `${maxHeight}px` : undefined,
        "font-family": fontStyle == "monospace" ? "monospace" : undefined,
        "grid-template-columns": templateColumns,
        "grid-template-rows": `${ROW_HEIGHT_PX}px repeat(${displayRowCount.value}, min-content)`,
    };
});

/**
 * Computed value to determine the style of the endpoint.
 * @type {ComputedRef<Object>}
 */
const endpointStyle = computed(() => {
    const totalHeight = ROW_HEIGHT_PX * rowCount.value;
    return {
        top: `${totalHeight}px`,
    };
});

/**
 * Handles the scroll event for the grid container.
 * Updates the relativePosition based on the scrollTop value.
 * 
 * @param ev - The scroll Event object
 */
function handleScroll(ev: Event) {
    const scrollTop = gridContainerEl.value.scrollTop;
    relativePosition.value =
        scrollTop /
        (gridContainerEl.value.scrollHeight -
            gridContainerEl.value.clientHeight);
}

/**
 * Resets the scroll position of the grid container to 0.
 */
function resetScroll() {
    gridContainerEl.value.scrollTop = 0;
}

/**
 * Recalculates the widths of the grid columns.
 */
async function recalculateColumnWidths() {
    columnWidths.value = [];
    await nextTick();
    const columnHeadersEls = gridContainerEl.value?.querySelectorAll(
        "[data-streamsync-grid-col]",
    );
    columnHeadersEls?.forEach((headerEl) => {
        const headerHTMLEl = headerEl as HTMLElement;
        const columnPosition = headerHTMLEl.dataset.streamsyncGridCol;
        const { width: autoWidth } = headerHTMLEl.getBoundingClientRect();
        const newWidth = Math.min(autoWidth, MAX_COLUMN_AUTO_WIDTH_PX);
        columnWidths.value[columnPosition] = newWidth;
    });
}

/**
 * Sets the sorting order for the table.
 * Using ASCENDING since pandas uses that, here we use descending, so we need to invert
 * 
 * @param ev - The mouse event that triggered the sorting
 * @param columnName - The name of the column to sort
 */
// Using ASCENDING since pandas uses that, here we use descending, so we need to invert
function handleSetOrder(ev: MouseEvent, columnName: string) {
    const targetEl = ev.target as HTMLElement;
    if (targetEl.classList.contains("widthAdjuster")) return;
    const currentColumnName = orderSetting.value?.columnName;

    if (currentColumnName !== columnName) {
        orderSetting.value = {
            columnName,
            descending: false,
        };
        emitSortColumnEvent(columnName, true);
        return;
    }

    const currentlyDescending = orderSetting.value?.descending;

    if (currentlyDescending) {
        orderSetting.value = null;
        emitSortColumnEvent(columnName, null);
        return;
    }

    orderSetting.value = {
        columnName,
        descending: !orderSetting.value.descending,
    };
    emitSortColumnEvent(columnName, !orderSetting.value.descending);
}

/**
 * Retrieves the index columns from an Arrow Table's schema metadata.
 *
 * @function
 * @param {Table<any>} table - The Arrow Table object.
 * @returns {Array<string>|Array<number>|[]} An array of index column names or an empty array if metadata is not present.
 */
function getIndexFromArrowTable(table: Table<any>) {
    const pandasMetadataJSON = table.schema.metadata.get("pandas");
    if (!pandasMetadataJSON) return [];
    const pandasMetadata = JSON.parse(pandasMetadataJSON);
    return pandasMetadata.index_columns;
}

/**
 * Fetches table data from a URL and sets the table state.
 */
async function loadData() {
    const url = fields.dataframe.value;

    try {
        const res = await fetch(url);
        const blob = await res.blob();
        const buffer = await blob.arrayBuffer();
        const arrowTable = tableFromIPC(buffer);
        tableIndex.value = getIndexFromArrowTable(arrowTable);
        const aqTable = aq.fromArrow(arrowTable);
        baseTable = aqTable;
        table.value = baseTable;
    } catch (e) {
        console.error("Couldn't load dataframe from Arrow URL.", e);
    }
}

/**
 * Checks if a value is numeric.
 * 
 * @param value - The value to check
 * @returns True if the value is numeric, otherwise false
 */
function isNumeric(value: any) {
    if (typeof value === 'number') {
        return true;
    }
    if (typeof value === 'string') {
        return !isNaN(parseFloat(value)) && isFinite(parseFloat(value));
    }
    return false;
}

/**
 * Formats a numeric value for display.
 * 
 * @param value - The numeric value to format
 * @returns The formatted string
 */
function formatNumber(value: any) {
    const absNum = Math.abs(parseFloat(value));
    if (absNum < 0.01 && absNum !== 0) {
        return value.toExponential(2);
    } else {
        return parseFloat(value).toFixed(2);
    }
}

/**
 * Emits an event when a row is clicked.
 * 
 * @param row - The data of the clicked row
 */
function emitRowClickEvent(row: Record<string, any>) {
    console.log("Emitting row click event", row);
    const event = new CustomEvent("row-click", {
        detail: {
            payload: row
        },
    });
    rootEl.value.dispatchEvent(event);
}

/**
 * Emits an event when a column is sorted.
 * 
 * @param column - The name of the sorted column
 * @param ascending - The sorting direction
 */
function emitSortColumnEvent(column: string, ascending: boolean | null) {
    const payload = {
        "column": column,
        "ascending": ascending
    };
    const event = new CustomEvent("sort-column", {
        detail: {
            payload,
        },
    });
    console.log("Emitting row click event", event);
    rootEl.value.dispatchEvent(event);
}

/**
 * Emits a paging event when one of the paging buttons is clicked.
 * 
 * @param row_or_page - Indicates whether to page by 'row' or 'page'
 * @param direction - The paging direction ('left' or 'right')
 */
function paging(row_or_page: string, direction: string) {
    const payload = {
        "row_or_page": row_or_page,
        "direction": direction,
        "pageSize": formValue.value
    };
    const event = new CustomEvent("paging", {
        detail: {
            payload,
        },
    });
    rootEl.value.dispatchEvent(event);
}

/**
 * Handles changes in the search input and emits a search event.
 * 
 * @param ev - The input event from the search field
 */
function handleServerSearchChange(ev: InputEvent) {
    const searchText = (ev.target as HTMLInputElement).value;
    const payload = {
        "search_text": searchText,
    };
    const event = new CustomEvent("search", {
        detail: {
            payload,
        },
    });
    rootEl.value.dispatchEvent(event);

}

/**
 * Emits a download event when the download button is clicked.
 */
function emitDownloadEvent() {
    const payload = {
        "download_triggered": true,
    };
    const event = new CustomEvent("download", {
        detail: {
            payload
        },
    });
    rootEl.value.dispatchEvent(event);
}

/**
 * Handles column width adjustment via mouse drag.
 * 
 * @param ev - The mouse event for dragging
 */
async function handleWidthAdjust(ev: MouseEvent) {
    if (ev.buttons !== 1) {
        columnBeingWidthAdjusted = null;
        return;
    }

    const targetEl = ev.target as HTMLElement;
    if (
        columnBeingWidthAdjusted === null &&
        targetEl.classList.contains("widthAdjuster")
    ) {
        const adjustedColEl = targetEl.closest(".cell") as HTMLElement;
        columnBeingWidthAdjusted = parseInt(
            adjustedColEl.dataset.streamsyncGridCol,
        );
    } else if (columnBeingWidthAdjusted === null) {
        return;
    }

    const colEl = gridContainerEl.value.querySelector(
        `[data-streamsync-grid-col="${columnBeingWidthAdjusted}"]`,
    );
    const adjusterEl = colEl.querySelector(".widthAdjuster");
    const { width: adjusterWidth } = adjusterEl.getBoundingClientRect();
    const { left: colLeft } = colEl.getBoundingClientRect();
    const mouseX = ev.clientX;
    const newWidth = mouseX - colLeft + adjusterWidth / 2;
    columnWidths.value[columnBeingWidthAdjusted] = newWidth;
}

watch(fields.dataframe, () => {
    loadData();
});

watch(columnCount, () => {
    recalculateColumnWidths();
});

watch(fields.wrapText, () => {
    recalculateColumnWidths();
});

onMounted(async () => {
    await loadData();
    document.addEventListener("mousemove", handleWidthAdjust);
    if (!toolsEl.value) return;
    new ResizeObserver(recalculateColumnWidths).observe(toolsEl.value, {
        box: "border-box",
    });
});

onUnmounted(() => {
    document.removeEventListener("mousemove", handleWidthAdjust);
});
</script>

<style scoped>
@import "../renderer/sharedStyles.css";

.ServerDataframe {
    font-size: 0.8rem;
    width: 100%;
}

.tools {
    display: flex;
    gap: 16px;
    align-items: center;
    color: var(--primaryTextColor);
    justify-content: right;
}

.tools:not(:empty) {
    margin-bottom: 16px;
}

.tools .search {
    display: flex;
    align-items: center;
    gap: 8px;
    border: 1px solid var(--separatorColor);
    padding: 8px 8px 8px 12px;
    border-radius: 8px;
    color: var(--buttonTextColor);
    background: var(--buttonColor);
}

.pagingtools {
    display: flex;
    gap: 8px;
    padding: 8px 8px 8px 12px;
    align-items: center;
    color: var(--buttonTextColor);
    justify-content: space-between;
    /* This separates the left and right containers */
}

.left-container,
.right-container {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 8px 8px 12px;
}

.tools .search input {
    border: 0;
    color: var(--buttonTextColor);
    background: var(--buttonColor);
}

.gridContainer {
    background: var(--dataframeBackgroundColor);
    position: relative;
    overflow: auto;
    border: 1px solid var(--separatorColor);
    max-height: 90vh;
}

.grid {
    margin-bottom: -1px;
    position: sticky;
    top: 0;
    display: grid;
}

.cell {
    min-height: 36px;
    padding: 8px;
    overflow: hidden;
    color: var(--primaryTextColor);
    border-bottom: 1px solid var(--separatorColor);
    display: flex;
    align-items: center;
    border-right: 1px solid var(--separatorColor);
    white-space: nowrap;
}

.cell.numeric {
    /* Your numeric styling here, e.g., text-alignment or color */
    /* The cell is using fledx, so need to use justify-content */
    justify-content: flex-end;
}

.grid.wrapText .cell {
    white-space: normal;
}

.cell.headerCell {
    padding: 0;
    cursor: pointer;
    gap: 8px;
    user-select: none;
}

.grid.scrolled .cell.headerCell {
    box-shadow: 0 4px 4px 0px rgba(0, 0, 0, 0.08);
}

.cell .name {
    padding: 8px;
    flex: 1 1 auto;
    overflow: hidden;
    text-overflow: ellipsis;
}

.cell .icon {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    visibility: hidden;
}

.cell .widthAdjuster {
    cursor: col-resize;
    min-width: 16px;
    flex: 0 0 16px;
    height: 100%;
    margin-right: -1px;
}

.cell:hover .widthAdjuster {
    background-color: var(--separatorColor);
}

.indexCell {
    color: var(--secondaryTextColor);
}

.endpoint {
    position: absolute;
    height: 1px;
    width: 1px;
}

.CoreSelectInput {
    width: fit-content;
    max-width: 100%;
}

label {
    color: var(--primaryTextColor);
}

select {
    max-width: 100%;
}

.selectContainer {
    margin-top: 8px;
}
</style>
FabienArcellier commented 1 year ago

That look awesome :tada: :tada:. I have to test that.

It's a great idea to have a repo where you can share community modules. It would even be ideal if we could install them like this:

pip install streamsync-ssr-dataframe

I am very interested with this topic.

ramedina86 commented 9 months ago

Always great to see people creating custom components. We have a few things planned, and @FabienArcellier is leading our efforts of expansibility. I'm closing this issue but rest assured we'll keep working on Streamsync more expansible.

Community is closely related subject, and we want to showcase them. I think I'd prefer people to keep them in their own repositories, as I'd like them to have full credit and ownership of the works, but happy to link it. As of now, until we get to have a nice community page, I can offer a pinned Discussions message and link from there (if you have a repo let me know and I'll gladly share it).

anubhavrohatgi commented 8 months ago

Hi @thondeboer ,

Can you provide a working example code. I am not able to perform any action on row.

thondeboer commented 8 months ago

Yeah, interaction is not trivial, but here's my test main.py and ui.json for using the server_dataframe component.

"""Test program for using the serverDataframe component in StreamSync."""

import streamsync as ss
import pandas as pd
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.csv as csv
import math
import json

import numpy as np
import random
import string
from typing import List, Dict, Optional, Any

from bionyx.streamsync import (
    handle_search,
    handle_sorting,
    handle_paging,
    handle_table_download,
    handle_clicked_row,
    show_payload
)

tabs = ["aap", "noot", "mies", "zus"]
n = 10_000

def _generate_gene_names(n: int) -> List[str]:
    """
    Generates a list of gene names, each starting with a capital letter followed by a mix of 4-6 lowercase letters and numbers.

    The function iterates 'n' times, each time randomly selecting a capital letter and a combination of 4-6 lowercase letters and digits
    to form a unique gene name. These names mimic the format often seen in genetic nomenclature.

    Parameters:
    n (int): The number of gene names to be generated.

    Returns:
    List[str]: A list containing 'n' randomly generated gene names.
    """
    gene_names = []
    for _ in range(n):
        start_letter = random.choice(string.ascii_uppercase)
        remaining_chars = ''.join(random.choice(
            string.ascii_lowercase + string.digits) for _ in range(random.randint(4, 6)))
        gene_names.append(start_letter + remaining_chars)
    return gene_names

def _generate_description(n: int) -> List[str]:
    """
    Generates a list of gene descriptions, each being a random string of 5 to 20 words.

    For each of the 'n' descriptions, this function randomly selects between 5 and 20 words from a vocabulary file,
    forming sentences that serve as mock descriptions for genes.

    Parameters:
    n (int): The number of descriptions to generate.

    Returns:
    List[str]: A list containing 'n' randomly generated gene descriptions.
    """
    with open("/usr/share/dict/words", "r") as f:
        words = f.read().splitlines()

    descriptions = []
    for _ in range(n):
        sentence = ' '.join(random.choices(words, k=random.randint(5, 20)))
        descriptions.append(sentence.capitalize())
    return descriptions

def generate_random_strings(n):
    """
    Generates n strings, each containing 10 random digits.

    Parameters:
    n (int): The number of strings to generate.

    Returns:
    list: A list containing n strings, each string with 10 random digits.
    """
    random_strings = []
    for _ in range(n):
        # Generate a string of 10 random digits
        random_string = ''.join(str(random.randint(0, 9)) for _ in range(10))
        random_strings.append(random_string)

    return random_strings

def _generate_pvalues(n: int) -> List[float]:
    """
    Generates a list of random p-values, each being a float between 0 and 1.

    This function creates 'n' random floats, representing p-values commonly used in statistical analyses in bioinformatics,
    where each value is between 0 and 1.

    Parameters:
    n (int): The number of p-values to generate.

    Returns:
    List[float]: A list containing 'n' randomly generated p-values.
    """
    return [random.uniform(0, 1) for _ in range(n)]

def _generate_tpm(n: int) -> List[float]:
    """
    Generates a list of TPM (Transcripts Per Million) values, each being a random float between 0 and 1E6.

    This function simulates 'n' TPM values, which are commonly used in transcriptome analyses. Each value is a random float
    within the range of typical TPM values (0 to 1E6).

    Parameters:
    n (int): The number of TPM values to generate.

    Returns:
    List[float]: A list containing 'n' randomly generated TPM values.
    """
    return [random.uniform(0, 1E6) for _ in range(n)]

def _create_patable(rows: int) -> pd.DataFrame:
    """
    Creates a PyArrow Table with a specified number of rows, containing columns for gene names, descriptions, p-values, and TPM values.

    The function generates a dataset mimicking a typical gene expression study dataset. It includes gene names, gene descriptions,
    statistical p-values, and TPM values for each row, generated using auxiliary functions.

    Parameters:
    rows (int): The number of rows to include in the dataframe.

    Returns:
    pa.Table: A PyArrow Table with the specified number of rows and the aforementioned columns.
    """
    df = pa.table({
        # Special invisible column - Used to keep track of the row index for sorting
        '__index_level_0__': pa.array(np.arange(rows), type=pa.int32()),
        'index': pa.array(np.arange(rows), type=pa.int32()),
        'gene': _generate_gene_names(rows),
        'Description': _generate_description(rows),
        'Description2': _generate_description(rows),
        # Explicitly specify string type
        'Sample': pa.array(generate_random_strings(rows), type=pa.string()),
        'Pvalue': _generate_pvalues(rows),
        'TPM': _generate_tpm(rows)
    })
    return df

# Suggested usage
# If only one dataframe us used, use the 'df', '_df' and row keys for the dataframe
# that is rendered in the UI and the hidden dataframe respectively,
# otherwise use the 'dfs' and '_dfs' keys for the dictionary of dataframes
# and the dictionary of hidden dataframes respectively. This allows the use of
# the same handler functions for both single and multiple dataframes.
# Use ther repeater component to render the dataframes in the UI for multiple
# dataframes.
# allow_multiselect: yes/no must be a state variable since it is used in the
# UI and in the handler functions. equally, selected_rows must be a state
# variable since it is used in the UI and in the handler functions.

initial_state = ss.init_state({
    "_df": _create_patable(n),   # The hidden dataframe contains the full dataset
    "_row": 0,                   # The hidden row index is used for sorting etc.
    "_dff": None,                # The filtered dataframe after searching
    "df": None,                  # The dataframe rendered in the UI
    "rows": "0/0",               # The number of rows in the dataframe is shown in the UI
    "enablePaging": "yes",       # Enables paging in the UI. Does not need to be a state variable
    "pageSize":10,               # Determines the number of rows showing per page
    "pages": "0/0",              # The number of pages in the dataframe is shown in the UI
    "allow_multiselect": "yes",  # Allows the user to select multiple rows, must be a state variable to be effective
    "selected_rows": [],         # The list of selected rows, must be a state variable to be effective
    "dfs": {
        k: {
            "df": None,
            "rows": "0/0",
            "pages": "0/0",
            "allow_multiselect": "yes",
            "selected_rows": [],
            "pageSize": 10,
            "enablePaging": "yes"
        } for k in ["df1", "df2"]
    },
    "_dfs": {
        k: {
            "_df": _create_patable(n),
            "_dff": None,
            "_row": 0,
        } for k in ["df1", "df2"]
    },
    "message": "Log messages will appear here",
})

# Load the first page of the data
for itemId in initial_state["dfs"].to_dict().keys():
    handle_search(initial_state, None, {"itemId": itemId})
# And load the first page of the single dataframe
# by setting the context to None
handle_search(initial_state, None, None)

the UI


{
    "metadata": {
        "streamsync_version": "0.2.8"
    },
    "components": {
        "root": {
            "id": "root",
            "type": "root",
            "content": {
                "appName": "My App"
            },
            "parentId": null,
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "c0f99a9e-5004-4e75-a6c6-36f17490b134": {
            "id": "c0f99a9e-5004-4e75-a6c6-36f17490b134",
            "type": "page",
            "content": {
                "pageMode": "wide",
                "emptinessColor": "#ffffff"
            },
            "parentId": "root",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "a0b5ba47-46d2-4bed-83d6-171e3c67e46c": {
            "id": "a0b5ba47-46d2-4bed-83d6-171e3c67e46c",
            "type": "textareainput",
            "content": {
                "label": "Log messages",
                "placeholder": "@{message}",
                "rows": "20"
            },
            "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134",
            "position": 1,
            "handlers": {},
            "visible": true
        },
        "0b27c66a-a516-4268-a3b5-4ffe3715374e": {
            "id": "0b27c66a-a516-4268-a3b5-4ffe3715374e",
            "type": "repeater",
            "content": {
                "repeaterObject": "{ \"df1\": \"Top dataframe\" }",
                "keyVariable": "itemId",
                "valueVariable": "item"
            },
            "parentId": "58f6f7a1-9b23-42b4-8a0f-d1443f3bae0c",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "c9b8744a-971c-4f96-b8c3-24502ba3a488": {
            "id": "c9b8744a-971c-4f96-b8c3-24502ba3a488",
            "type": "repeater",
            "content": {
                "repeaterObject": "{ \"df2\": \"Bottom dataframe\" }",
                "keyVariable": "itemId",
                "valueVariable": "item"
            },
            "parentId": "58f6f7a1-9b23-42b4-8a0f-d1443f3bae0c",
            "position": 1,
            "handlers": {},
            "visible": true
        },
        "79721d50-5d5a-46fb-a1a4-6d64be482144": {
            "id": "79721d50-5d5a-46fb-a1a4-6d64be482144",
            "type": "custom_serverdataframe",
            "content": {
                "dataframeTitle": "Top Dataframe",
                "dataframe": "@{dfs.df1.df}",
                "allowMultiSelect": "@{dfs.df1.allow_multiselect}",
                "enablePaging": "@{dfs.df1.enablePaging}",
                "displayRowCount": "10",
                "totalRowCount": "@{dfs.df1.rows}",
                "selectedRows": "@{dfs.df1.selected_rows}",
                "pageRowCount": "@{dfs.df1.pages}"
            },
            "parentId": "0b27c66a-a516-4268-a3b5-4ffe3715374e",
            "position": 0,
            "handlers": {
                "sort-column": "handle_sorting",
                "row-click": "handle_clicked_row",
                "paging": "handle_paging",
                "search": "handle_search",
                "download": "handle_table_download"
            },
            "visible": true
        },
        "2be42caf-1df4-41ba-b061-3cd5e6874be6": {
            "id": "2be42caf-1df4-41ba-b061-3cd5e6874be6",
            "type": "custom_serverdataframe",
            "content": {
                "dataframeTitle": "Bottom Dataframe",
                "dataframe": "@{dfs.df2.df}",
                "allowMultiSelect": "@{dfs.df2.allow_multiselect}",
                "enablePaging": "@{dfs.df2.enablePaging}",
                "displayRowCount": "10",
                "totalRowCount": "@{dfs.df2.rows}",
                "selectedRows": "@{dfs.df2.selected_rows}",
                "pageRowCount": "@{dfs.df2.pages}"
            },
            "parentId": "c9b8744a-971c-4f96-b8c3-24502ba3a488",
            "position": 0,
            "handlers": {
                "sort-column": "handle_sorting",
                "row-click": "handle_clicked_row",
                "paging": "handle_paging",
                "search": "handle_search",
                "download": "handle_table_download"
            },
            "visible": true
        },
        "dbf0b3bc-aa98-40af-aab8-087ad5f75468": {
            "id": "dbf0b3bc-aa98-40af-aab8-087ad5f75468",
            "type": "tabs",
            "content": {},
            "parentId": "b18f1db6-07ac-4752-ae02-2978c6b5967f",
            "position": 1,
            "handlers": {},
            "visible": true
        },
        "6ec04418-2cdb-4048-9273-1b72ec17bfbb": {
            "id": "6ec04418-2cdb-4048-9273-1b72ec17bfbb",
            "type": "repeater",
            "content": {
                "repeaterObject": "@{dfs}",
                "keyVariable": "itemId",
                "valueVariable": "item"
            },
            "parentId": "dbf0b3bc-aa98-40af-aab8-087ad5f75468",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "0dd5460d-24c1-43db-bb05-71ab9d82cfc8": {
            "id": "0dd5460d-24c1-43db-bb05-71ab9d82cfc8",
            "type": "tab",
            "content": {
                "name": "@{itemId}"
            },
            "parentId": "6ec04418-2cdb-4048-9273-1b72ec17bfbb",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "87196a81-dd89-4771-a609-16df7688f73b": {
            "id": "87196a81-dd89-4771-a609-16df7688f73b",
            "type": "custom_serverdataframe",
            "content": {
                "dataframe": "@{item.df}",
                "selectedRows": "@{item.selected_rows}",
                "allowMultiSelect": "@{item.allow_multiselect}",
                "totalRowCount": "@{item.rows}",
                "pageRowCount": "@{item.pages}",
                "enablePaging": "@{item.enablePaging}"
            },
            "parentId": "0dd5460d-24c1-43db-bb05-71ab9d82cfc8",
            "position": 0,
            "handlers": {
                "row-click": "show_payload",
                "sort-column": "handle_sorting",
                "paging": "handle_paging",
                "search": "handle_search",
                "download": "handle_table_download"
            },
            "visible": true
        },
        "9cce28e8-bac6-4b48-8dc3-19be3fcf3e9f": {
            "id": "9cce28e8-bac6-4b48-8dc3-19be3fcf3e9f",
            "type": "heading",
            "content": {
                "text": "These dataframes are the same as the top and bottom, but are using the DFS as repeaters",
                "headingType": "h1"
            },
            "parentId": "b18f1db6-07ac-4752-ae02-2978c6b5967f",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "d76bdc86-2e68-409d-a171-be71cbbda13a": {
            "id": "d76bdc86-2e68-409d-a171-be71cbbda13a",
            "type": "columns",
            "content": {},
            "parentId": "c0f99a9e-5004-4e75-a6c6-36f17490b134",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "58f6f7a1-9b23-42b4-8a0f-d1443f3bae0c": {
            "id": "58f6f7a1-9b23-42b4-8a0f-d1443f3bae0c",
            "type": "column",
            "content": {
                "width": "1"
            },
            "parentId": "d76bdc86-2e68-409d-a171-be71cbbda13a",
            "position": 0,
            "handlers": {},
            "visible": true
        },
        "b18f1db6-07ac-4752-ae02-2978c6b5967f": {
            "id": "b18f1db6-07ac-4752-ae02-2978c6b5967f",
            "type": "column",
            "content": {
                "width": "1"
            },
            "parentId": "d76bdc86-2e68-409d-a171-be71cbbda13a",
            "position": 1,
            "handlers": {},
            "visible": true
        },
        "34f19f46-4097-4309-9936-0b2fe1d61230": {
            "id": "34f19f46-4097-4309-9936-0b2fe1d61230",
            "type": "custom_serverdataframe",
            "content": {
                "dataframe": "@{df}",
                "totalRowCount": "@{rows}",
                "pageRowCount": "@{pages}",
                "allowMultiSelect": "yes",
                "selectedRows": "@{selected_rows}"
            },
            "parentId": "53e6d419-649d-4a5a-a66e-43a870a5d23b",
            "position": 0,
            "handlers": {
                "row-click": "handle_clicked_row",
                "sort-column": "handle_sorting",
                "paging": "handle_paging",
                "search": "handle_search",
                "download": "handle_table_download"
            },
            "visible": true
        },
        "53e6d419-649d-4a5a-a66e-43a870a5d23b": {
            "id": "53e6d419-649d-4a5a-a66e-43a870a5d23b",
            "type": "section",
            "content": {
                "title": "This is a single dataframe, not using repeaters",
                "snapMode": "no"
            },
            "parentId": "b18f1db6-07ac-4752-ae02-2978c6b5967f",
            "position": 2,
            "handlers": {},
            "visible": true
        }
    }
}
...