UNPKG

simplyflow

Version:

Flow based programming in javascript, with signals and effects

257 lines (243 loc) 8.42 kB
import {signal, effect, throttledEffect, batch} from './state.mjs' /** * This class implements a pluggable data model, where you can * add effects that are run only when either an option for that * effect changes, or when an effect earlier in the chain of * effects changes. */ class SimplyFlowModel { /** * Creates a new datamodel, with a state property that contains * all the data passed to this constructor * @param state Object with all the data for this model */ constructor(state) { this.state = signal(state) if (!this.state.options) { this.state.options = {} } this.effects = [{current:state.data}] this.view = signal(state.data) } /** * Adds an effect to run whenever a signal it depends on * changes. this.state is the usual signal. * The `fn` function param is not itself an effect, but must return * and effect function. `fn` takes one param, which is the data signal. * This signal will always have at least a `current` property. * The result of the effect function is pushed on to the this.effects * list. And the last effect added is set as this.view */ addEffect(fn) { const dataSignal = this.effects[this.effects.length-1] this.view = fn.call(this, dataSignal) this.effects.push(this.view) } } export function model(options) { return new SimplyFlowModel(options) } /** * Returns a function for model.addEffect that sorts the input data * * Options: * - direction (string) default 'asc' - change to 'desc' to sort in descending order * - sortBy (string) (optional) - used by the default sorting function to select the property to sort on * - sortFn (function) (required - set by default) - the sort function to use */ export function sort(options={}) { return function(data) { // initialize the sort options, only gets called once this.state.options.sort = Object.assign({ direction: 'asc', sortBy: null, sortFn: ((a,b) => { const sort = this.state.options.sort const sortBy = sort.sortBy if (!sort.sortBy) { return 0 } const larger = sort.direction == 'asc' ? 1 : -1 const smaller = sort.direction == 'asc' ? -1 : 1 if (typeof a?.[sortBy] === 'undefined') { if (typeof b?.[sortBy] === 'undefined') { return 0 } return larger } if (typeof b?.[sortBy] === 'undefined') { return smaller } if (a[sortBy]<b[sortBy]) { return smaller } else if (a[sortBy]>b[sortBy]) { return larger } else { return 0 } }) }, options); // then return the effect, which is called when // either the data or the sort options change return throttledEffect(() => { const sort = this.state.options.sort if (sort?.sortBy && sort?.direction) { return data.current.toSorted(sort?.sortFn) } return data.current }, 50) } } /** * Returns a function for model.addEffect that implements paging * for the input data. It will return a slice of the data matching * the page and pageSize options. * * Options: * - page (int) default 1 - which page to show, starts at 1 * - pageSize (int) default 20 - how many items in a single page * - max (int) (calculated) - how many pages in total */ export function paging(options={}) { return function(data) { // initialize the paging options this.state.options.paging = Object.assign({ page: 1, pageSize: 20, max: 1 }, options) return throttledEffect(() => { return batch(() => { const paging = this.state.options.paging if (!paging.pageSize) { paging.pageSize = 20 } paging.max = Math.ceil(this.state.data.length / paging.pageSize) paging.page = Math.max(1, Math.min(paging.max, paging.page)) const start = (paging.page-1) * paging.pageSize const end = start + paging.pageSize return data.current.slice(start, end) }) }, 50) } } /** * Returns a function for model.addEffect that filters rows from the data, * using a custom filter function `options.matches` * * Options: * - name (string) (required) - the name of this filter, must be unique * - matches (function) (required) - the filter function to apply to the data */ export function filter(options) { if (!options?.name || typeof options.name!=='string') { throw new Error('filter requires options.name to be a string') } if (!options.matches || typeof options.matches!=='function') { throw new Error('filter requires options.matches to be a function') } return function(data) { if (this.state.options[options.name]) { throw new Error('a filter with this name already exists on this model') } this.state.options[options.name] = options return throttledEffect(() => { if (this.state.options[options.name].enabled) { return data.current.filter(this.state.options[options.name].matches.bind(this)) } return data.current }, 50) } } /** * Returns a function for model.addEffect that filters the data to only contain * columns (properties) that aren't hidden. Automatically runs again if any columns * hidden property changes. * * Options: * - columns (object) (required) - an object with properties describing each column. Each * property must be an object with an optional `hidden` property. If set to a truthy value, * and property in the dataset with the same name, will be filtered out. */ export function columns(options={}) { if (!options || typeof options!=='object' || Object.keys(options).length===0) { throw new Error('columns requires options to be an object with at least one property') } return function(data) { this.state.options.columns = options return throttledEffect(() => { return data.current.map(input => { let result = {} for (let key of Object.keys(this.state.options.columns)) { if (!this.state.options.columns[key]?.hidden) { result[key] = input[key] } } return result }) }, 50) } } /** * Returns a function for use with model.addEffect, with the given options set * as model.options.scroll. The effect will return a slice of the input data, which * makes it easy to render just a part (slice) of the whole data. * * Options are: * - offset (int) default 0 (optional) - the offset in the data to start the slice * - rowCount (int) default 20 (optional / calculated) - the number of rows in the slice * - rowHeight (int) default 26 (optional) - the height of a single row in pixels * - itemsPerRow (int) default 1 (optional) - the number of items on a single row * - size (int) default data.current.length (calculated) - how many rows inside data.current before slicing * - scrollbar (HTMLElement) defualt null (optional) - if set, an effect is added to update this elements * height if data.current.length changes * - container (HTMLElement) default null (optional) - if set, a scroll listener is added to this element, * which will update the options.offset signal and trigger the slice effect. It will also set the rowCount. */ export function scroll(options) { return function(data) { this.state.options.scroll = Object.assign({ offset: 0, rowHeight: 26, rowCount: 20, itemsPerRow: 1, size: data.current.length }, options) const scrollOptions = this.state.options.scroll const scrollbar = scrollOptions.scrollbar || scrollOptions.container?.querySelector('[data-flow-scrollbar]') if (scrollbar) { if (scrollOptions.container) { scrollOptions.container.addEventListener('scroll', (evt) => { scrollOptions.offset = Math.floor(scrollOptions.container.scrollTop / (scrollOptions.rowHeight*scrollOptions.itemsPerRow) ) }) } throttledEffect(() => { scrollOptions.size = data.current.length * scrollOptions.rowHeight scrollbar.style.height = scrollOptions.size + 'px' }, 50) } return throttledEffect(() => { if (scrollOptions.container) { //TODO: add a resize listener so that if the size of the container // changes, the rowCount is calculated again scrollOptions.rowCount = Math.ceil( scrollOptions.container.getBoundingClientRect().height / scrollOptions.rowHeight ) } scrollOptions.data = data.current let start = Math.min(scrollOptions.offset, data.current.length-1) let end = start + scrollOptions.rowCount if (end > data.current.length) { end = data.current.length start = end - scrollOptions.rowCount } return data.current.slice(start, end) }, 50) } }