Skip to content

Commit

Permalink
Data source improvements (#1575)
Browse files Browse the repository at this point in the history
  • Loading branch information
jpzwarte authored Oct 7, 2024
1 parent 75ecbb1 commit ebe4c8a
Show file tree
Hide file tree
Showing 34 changed files with 966 additions and 73 deletions.
15 changes: 15 additions & 0 deletions .changeset/lazy-rockets-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@sl-design-system/data-source': patch
---

New `@sl-design-system/data-source` package

This packages provides `DataSource`, `ArrayDataSource` and `FetchDataSource` classes
for managing data sources in the design system. At the moment, it is only used by the
grid components, but it can be used in future components as well.

`DataSource` and `ArrayDataSource` were previously part of the `@sl-design-system/shared`
package, but they have been moved to this new package to make them more reusable.

`FetchDataSource` is a new data source around the `window.fetch()` API that can be used to
fetch data from a remote server.
9 changes: 9 additions & 0 deletions .changeset/modern-baboons-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@sl-design-system/shared': minor
'@sl-design-system/grid': minor
---

Migrate `DataSource` and `ArrayDataSource` to dedicated `@sl-design-system/data-source` package.

Since these are only used in the grid component, and that component is still in draft, migrating
this code into its own package is not considered a breaking change.
15 changes: 15 additions & 0 deletions .changeset/serious-yaks-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@sl-design-system/grid': patch
---

Automatically render an `<sl-skeleton>` component in each `<td>` element

When an item to be rendered equals `FetchDataSourcePlaceholder`, the column will render a
skeleton component instead of the item itself. This will help users understand that the
data is being fetched and will be displayed soon.

You have the option to customize the skeleton component by passing custom `renderer` function
to the column component. See Storybook for an example.

You will automatically get this behavior if you use the `FetchDataSource` (from the
`@sl-design-system/data-source` package) with the grid.
3 changes: 3 additions & 0 deletions packages/components/data-source/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './src/array-data-source.js';
export * from './src/data-source.js';
export * from './src/fetch-data-source.js';
37 changes: 37 additions & 0 deletions packages/components/data-source/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@sl-design-system/data-source",
"version": "0.0.0",
"description": "Data source classes for the SL Design System",
"license": "Apache-2.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"repository": {
"type": "git",
"url": "https://github.com/sl-design-system/components.git",
"directory": "packages/components/data-source"
},
"homepage": "https://sanomalearning.design/components/data-source",
"bugs": {
"url": "https://github.com/sl-design-system/components/issues"
},
"type": "module",
"main": "./index.js",
"module": "./index.js",
"types": "./index.d.ts",
"exports": {
".": "./index.js",
"./package.json": "./package.json"
},
"files": [
"**/*.d.ts",
"**/*.js",
"**/*.js.map"
],
"scripts": {
"test": "echo \"Error: run tests from monorepo root.\" && exit 1"
},
"dependencies": {
"@sl-design-system/shared": "^0.3.2"
}
}
153 changes: 153 additions & 0 deletions packages/components/data-source/src/array-data-source.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { expect } from '@open-wc/testing';
import { spy } from 'sinon';
import { ArrayDataSource } from './array-data-source.js';
import { type Person, people } from './data-source.spec.js';

describe('ArrayDataSource', () => {
let ds: ArrayDataSource<Person>;

describe('basics', () => {
beforeEach(() => {
ds = new ArrayDataSource(people);
});

it('should have items', () => {
expect(ds.items).to.deep.equal(people);
});

it('should not have filtered any items', () => {
expect(ds.items).to.deep.equal(people);
});

it('should have a size', () => {
expect(ds.size).to.equal(people.length);
});

it('should not have filtering', () => {
expect(ds.filters.size).to.equal(0);
});

it('should not have grouping', () => {
expect(ds.groupBy).to.be.undefined;
});

it('should not have sorting', () => {
expect(ds.sort).to.be.undefined;
});

it('should emit an sl-update event when calling update()', () => {
const onUpdate = spy();

ds.addEventListener('sl-update', onUpdate);
ds.update();

expect(onUpdate).to.have.been.calledOnce;
});
});

describe('filtering', () => {
beforeEach(() => {
ds = new ArrayDataSource(people);
});

it('should filter by path', () => {
ds.addFilter('id', 'profession', 'Gastroenterologist');
ds.update();

expect(ds.items).to.have.length(2);
expect(ds.items.every(({ profession }) => profession === 'Gastroenterologist')).to.be.true;
});

it('should filter the same path using an OR operator', () => {
ds.addFilter('id1', 'membership', 'Regular');
ds.addFilter('id2', 'membership', 'Premium');
ds.update();

expect(ds.items).to.have.length(4);
expect(ds.items.every(({ membership }) => ['Regular', 'Premium'].includes(membership))).to.be.true;
});

it('should filter numbers as well as strings', () => {
ds.addFilter('id', 'id', '1');
ds.update();

expect(ds.items).to.have.length(1);
expect(ds.items[0].id).to.equal(1);
});

it('should filter whitespace, null and undefined as blank values', () => {
ds.addFilter('id', 'pictureUrl', '');
ds.update();

expect(ds.items).to.have.length(4);
});

it('should filter by function', () => {
ds.addFilter('search', ({ firstName, lastName }) => {
return /Ann/.test(firstName) || /Ann/.test(lastName);
});
ds.update();

expect(ds.items).to.have.length(2);
expect(ds.items.every(({ firstName, lastName }) => /Ann/.test(firstName) || /Ann/.test(lastName))).to.be.true;
});

it('should combine filters', () => {
ds.addFilter('id1', 'profession', 'Gastroenterologist');
ds.addFilter('id2', 'status', 'Busy');
ds.addFilter('id3', ({ firstName }) => /Bob/.test(firstName));
ds.update();

expect(ds.items).to.have.length(1);
expect(ds.items[0].firstName).to.equal('Bob');
expect(ds.items[0].profession).to.equal('Gastroenterologist');
expect(ds.items[0].status).to.equal('Busy');
});

it('should reset the filtered items when removing a filter', () => {
ds.addFilter('id', 'profession', 'Gastroenterologist');
ds.update();

expect(ds.items).to.have.length(2);

ds.removeFilter('id');
ds.update();

expect(ds.items).to.deep.equal(people);
});
});

describe('grouping', () => {});

describe('sorting', () => {
beforeEach(() => {
ds = new ArrayDataSource(people);
});

it('should sort by path', () => {
ds.setSort('id', 'firstName', 'asc');
ds.update();

expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'Ann', 'Bob', 'Jane', 'John']);
});

it('should sort in a descending direction', () => {
ds.setSort('id', 'firstName', 'desc');
ds.update();

expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['John', 'Jane', 'Bob', 'Ann', 'Ann']);
});

it('should reset the original order when removing a sort', () => {
ds.setSort('id', 'firstName', 'asc');
ds.update();

expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'Ann', 'Bob', 'Jane', 'John']);

ds.removeSort();
ds.update();

expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'John', 'Jane', 'Ann', 'Bob']);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { getStringByPath, getValueByPath } from '../path.js';
import { DataSource, type DataSourceSortFunction } from './data-source.js';
import { getStringByPath, getValueByPath } from '@sl-design-system/shared';
import {
DataSource,
type DataSourceFilterByFunction,
type DataSourceFilterByPath,
type DataSourceSortFunction
} from './data-source.js';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ArrayDataSource<T = any> extends DataSource<T> {
#filteredItems: T[] = [];
#items: T[];

get filteredItems(): T[] {
return this.#filteredItems;
}

get items(): T[] {
return this.#items;
return this.#filteredItems;
}

set items(items: T[]) {
Expand All @@ -32,26 +33,47 @@ export class ArrayDataSource<T = any> extends DataSource<T> {
update(): void {
let items: T[] = [...this.#items];

// Filter the items
for (const filter of this.filters.values()) {
if ('filter' in filter && filter.filter) {
items = items.filter(filter.filter);
} else if ('path' in filter && filter.path) {
const { path, value } = filter;

const regexes = (Array.isArray(value) ? value : [value])
.filter((v): v is string => typeof v === 'string')
.map(v => (v === '' ? /^\s*$/ : new RegExp(v, 'i')));

items = items.filter(item => {
const v = getValueByPath(item, path);
if (this.filters.size) {
const filters = Array.from(this.filters.values());

const pathFilters = filters
.filter((f): f is DataSourceFilterByPath => 'path' in f && !!f.path)
.reduce(
(acc, { path, value }) => {
if (!acc[path]) {
acc[path] = [];
}

if (Array.isArray(value)) {
acc[path].push(...value);
} else {
acc[path].push(value);
}

return acc;
},
{} as Record<string, string[]>
);

Object.entries(pathFilters).forEach(([path, values]) => {
/**
* Convert the value to a string and trim it, so we can match
* an empty string to:
* - ''
* - ' '
* - null
* - undefined
*/
items = items.filter(item => values.includes(getValueByPath(item, path)?.toString()?.trim() ?? ''));
});

return regexes.some(regex => regex.test(v?.toString() ?? ''));
filters
.filter((f): f is DataSourceFilterByFunction<T> => 'filter' in f && !!f.filter)
.forEach(f => {
items = items.filter(f.filter);
});
}
}

// Sort the items
if (this.sort) {
const ascending = this.sort.direction === 'asc';

Expand Down Expand Up @@ -104,6 +126,6 @@ export class ArrayDataSource<T = any> extends DataSource<T> {
}

this.#filteredItems = items;
this.dispatchEvent(new CustomEvent<void>('sl-update'));
this.dispatchEvent(new CustomEvent('sl-update', { detail: { dataSource: this } }));
}
}
Loading

0 comments on commit ebe4c8a

Please sign in to comment.