Skip to content

Commit

Permalink
feat(OnyxDataGrid): Expose Feature API (#2059)
Browse files Browse the repository at this point in the history
Relates to #1852 

- Added `features` prop, which exposes the `useDataGridFeature` API to
allow devs to use custom and pre-defined features
- Expose onyx provided data-grid features via a re-export called
`DataGridFeatures`
- Added `DataGridFeatures.useSorting` feature, with support for custom
configuration
- Fixed issue where `OnyxDataGrid` didn't update on sort change: A deep
watcher is necessary, even when a non-shallow ref is used
  • Loading branch information
JoCa96 authored Nov 11, 2024
1 parent 501e5aa commit 744f82e
Show file tree
Hide file tree
Showing 14 changed files with 909 additions and 698 deletions.
7 changes: 7 additions & 0 deletions .changeset/moody-swans-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"sit-onyx": minor
---

feat(OnyxDataGrid): Added `features` prop which exposes the `useDataGridFeature` API to allow devs to use custom and pre-defined features
feat(OnyxDataGrid): Expose onyx provided features via a re-export called `DataGridFeatures`
feat(OnyxDataGrid): Added `DataGridFeatures.useSorting` feature, with support for custom configuration
1 change: 1 addition & 0 deletions packages/sit-onyx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@sit-onyx/headless": "workspace:^",
"@sit-onyx/storybook-utils": "workspace:^",
"@storybook/addon-a11y": "^8.4.2",
"@storybook/addon-actions": "^8.4.2",
"@vue/compiler-dom": "catalog:",
"axe-core": "^4.10.2",
"eslint-plugin-vue-scoped-css": "^2.8.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/vue3";
import { h } from "vue";
import SortingDataGrid from "../examples/DataGrid/SortingDataGrid.vue";
import SortingDataGridExampleCode from "../examples/DataGrid/SortingDataGrid.vue?raw";
import OnyxDataGrid from "./OnyxDataGrid.vue";

const meta: Meta<typeof OnyxDataGrid> = {
Expand All @@ -19,3 +23,15 @@ export const Default = {
],
},
} satisfies Story;

export const Sorting = {
...Default,
render: (props) => h(SortingDataGrid, { ...props, onSortChange: action("sorting changed") }),
parameters: {
docs: {
source: {
code: SortingDataGridExampleCode.replaceAll('from "../../.."', 'from "sit-onyx"'),
},
},
},
} satisfies Story;
51 changes: 35 additions & 16 deletions packages/sit-onyx/src/components/OnyxDataGrid/OnyxDataGrid.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script setup lang="ts" generic="TEntry extends DataGridEntry">
import { ref, toRefs, watch, type Ref } from "vue";
<script
setup
lang="ts"
generic="TEntry extends DataGridEntry, TFeatures extends DataGridFeature<TEntry, symbol>[] | []"
>
import { computed, ref, toRefs, watch, type Ref, type WatchHandle } from "vue";
import {
OnyxDataGridRenderer,
type DataGridEntry,
Expand All @@ -8,28 +12,43 @@ import {
type DataGridRendererRow,
type OnyxDataGridProps,
} from "../..";
import { useDataGridFeatures } from "./features";
import { useDataGridSorting } from "./features/sorting/sorting";
import { useDataGridFeatures, type DataGridFeature } from "./features";
const props = defineProps<OnyxDataGridProps<TEntry>>();
const withSorting = useDataGridSorting<TEntry>();
const { watchSources, createRendererRows, createRendererColumns } = useDataGridFeatures([
withSorting,
]);
const props = withDefaults(defineProps<OnyxDataGridProps<TEntry, TFeatures>>(), {
features: () => [] as TFeatures,
});
// Using Ref types to avoid `UnwrapRef` issues
const renderColumns: Ref<DataGridRendererColumn<TEntry, object>[]> = ref([]);
const renderRows: Ref<DataGridRendererRow<TEntry, DataGridMetadata>[]> = ref([]);
const { columns, data } = toRefs(props);
const { columns, data, features } = toRefs(props);
const featureBuilder = computed<ReturnType<typeof useDataGridFeatures<TEntry, TFeatures>>>(() =>
useDataGridFeatures(features.value),
);
/**
* Function to be able to reset the watcher in case of the features being updated.
*/
let featureBuilderWatchHandle: WatchHandle | undefined;
const createFeatureBuilderWatcher = () => {
const { createRendererColumns, createRendererRows, watchSources } = featureBuilder.value;
return watch(
[columns, data, ...watchSources],
() => {
renderColumns.value = createRendererColumns(columns.value);
renderRows.value = createRendererRows(data.value, columns.value);
},
{ immediate: true, deep: true },
);
};
watch(
[columns, data, ...watchSources],
([newColumns, newData]) => {
renderColumns.value = createRendererColumns(newColumns);
renderRows.value = createRendererRows(newData, newColumns);
featureBuilder,
() => {
featureBuilderWatchHandle?.();
featureBuilderWatchHandle = createFeatureBuilderWatcher();
},
{ immediate: true },
);
Expand Down
3 changes: 3 additions & 0 deletions packages/sit-onyx/src/components/OnyxDataGrid/features/all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./sorting/types";

export { useDataGridSorting as useSorting } from "./sorting/sorting";
12 changes: 6 additions & 6 deletions packages/sit-onyx/src/components/OnyxDataGrid/features/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { h, type Component, type WatchSource } from "vue";
import type { DataGridRendererColumn, DataGridRendererRow } from "../../..";
import type { DataGridRendererColumn, DataGridRendererRow } from "../OnyxDataGridRenderer/types";
import type { DataGridEntry, DataGridMetadata } from "../types";
import HeaderCell from "./HeaderCell.vue";

Expand Down Expand Up @@ -57,13 +57,13 @@ export type DataGridFeature<TEntry extends DataGridEntry, TFeatureName extends s
* });
* ```
*/
export const createFeature = <TFeatureName extends symbol, TArgs extends unknown[]>(
export function createFeature<TFeatureName extends symbol, TArgs extends unknown[]>(
featureDefinition: <TEntry extends DataGridEntry>(
...args: TArgs
) => DataGridFeature<TEntry, TFeatureName>,
) => featureDefinition;

type ExtractTEntry<T> = T extends DataGridFeature<infer I, symbol>[] ? I : never;
) {
return featureDefinition;
}

/**
* Uses the defined datagrid features to provide factory functions.
Expand Down Expand Up @@ -100,11 +100,11 @@ type ExtractTEntry<T> = T extends DataGridFeature<infer I, symbol>[] ? I : never
* ```
*/
export const useDataGridFeatures = <
TEntry extends DataGridEntry,
// Intersection with the empty array is necessary for TypeScript to infer the array entries as tuple values instead of an array
// e.g. (Feature1 | Feature2)[] vs. [Feature1, Feature2]
// The inference of tuple values allows us to create types that are more precise
T extends DataGridFeature<TEntry, symbol>[] | [],
TEntry extends DataGridEntry = ExtractTEntry<T>,
>(
features: T,
) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import arrowsSort from "@sit-onyx/icons/arrows-sort.svg?raw";
import { computed } from "vue";
import { OnyxIconButton } from "../../../..";
import { injectI18n } from "../../../../i18n";
import type { SortDirection } from "./sorting";
import { nextSortDirection } from "./sorting";
import type { SortDirection } from "./types";
const props = defineProps<{
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { computed, h, ref } from "vue";
import { computed, h, toRef, toValue, type Ref } from "vue";
import { createFeature } from "..";
import { injectI18n } from "../../../../i18n";
import type { DataGridEntry } from "../../types";
import SortAction from "./SortAction.vue";

export type SortDirection = "asc" | "desc" | "none";
import type { SortDirection, SortOptions, SortState } from "./types";

export const nextSortDirection = (current?: SortDirection): SortDirection => {
switch (current) {
Expand All @@ -18,51 +17,90 @@ export const nextSortDirection = (current?: SortDirection): SortDirection => {
}
};

const SORTING_FEATURE = Symbol("Sorting");
export const nextSortState = <TEntry extends DataGridEntry>(
column: keyof TEntry,
current?: SortState<TEntry>,
): SortState<TEntry> => {
if (current?.column === column) {
return {
column: current.column,
direction: nextSortDirection(current.direction),
};
} else {
return { column, direction: nextSortDirection() };
}
};

export const SORTING_FEATURE = Symbol("Sorting");

export const useDataGridSorting = createFeature(
<TEntry extends DataGridEntry>(options?: SortOptions<TEntry>) => {
const sortState: Ref<SortState<TEntry>> = toRef(
options?.sortState ??
({
column: undefined,
direction: "none",
} as const),
);

const getSortFunc = computed(() => (col: keyof TEntry) => {
const config = toValue(options?.columns);
return config?.[col]?.sortFunc ?? intlCompare.value;
});

const getSortEnabled = computed(() => (col: keyof TEntry) => {
const config = toValue(options?.columns);
return !config || config?.[col]?.enabled === true;
});

export const useDataGridSorting = createFeature(<TEntry extends DataGridEntry>() => {
const sortColumn = ref<keyof TEntry>();
const sortDirection = ref<SortDirection>("none");
const locale = injectI18n().locale;
const intlCompare = computed(() => new Intl.Collator(locale.value).compare);
const locale = injectI18n()?.locale;
const intlCompare = computed(
() => (a: unknown, b: unknown) =>
new Intl.Collator(locale.value).compare(String(a), String(b)),
);

const handleClick = (column: keyof TEntry) => {
if (sortColumn.value === column) {
sortDirection.value = nextSortDirection(sortDirection.value);
} else {
sortColumn.value = column;
sortDirection.value = nextSortDirection();
}
if (sortDirection.value === "none") {
sortColumn.value = undefined;
}
};
const handleClick = (column: keyof TEntry) => {
if (sortState.value.column === column) {
sortState.value.direction = nextSortDirection(sortState.value.direction);
} else {
sortState.value = { column, direction: nextSortDirection() };
}
if (sortState.value.direction === "none") {
sortState.value.column = undefined;
}
};

const sortData = (data: Readonly<TEntry>[]) => {
const column = sortColumn.value;
if (!column || sortDirection.value === "none") {
return;
}
const multiplicand = sortDirection.value === "asc" ? 1 : -1;
data.sort((a, b) => intlCompare.value(String(a[column]), String(b[column])) * multiplicand);
};
const sortData = (data: Readonly<TEntry>[]) => {
const { column, direction } = sortState.value;
if (!column || direction === "none") {
return;
}
const multiplicand = direction === "asc" ? 1 : -1;
const sortFunc = getSortFunc.value(column);
data.sort((a, b) => sortFunc(a[column], b[column]) * multiplicand);
};

return {
name: SORTING_FEATURE,
watch: [sortColumn, sortDirection, locale],
mutation: {
func: sortData,
},
header: {
actions: (column) => [
{
iconComponent: h(SortAction, {
columnLabel: String(column),
sortDirection: sortColumn.value === column ? sortDirection.value : undefined,
onClick: () => handleClick(column),
}),
},
],
},
};
});
return {
name: SORTING_FEATURE,
watch: [sortState, intlCompare],
mutation: {
func: sortData,
},
header: {
actions: (column) =>
getSortEnabled.value(column) === true
? [
{
iconComponent: h(SortAction, {
columnLabel: String(column),
sortDirection:
sortState.value.column === column ? sortState.value.direction : undefined,
onClick: () => handleClick(column),
}),
},
]
: [],
},
};
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { type MaybeRef, type MaybeRefOrGetter } from "vue";
import type { DataGridEntry } from "../../types";

export type SortDirection = "asc" | "desc" | "none";

/**
* A function to compare two values of type T.
* The returned number value indicates the relative order of two values:
* -1: less than, 0: equal to, 1: greater than
*/
export type Compare<T> = (a: T, b: T) => number;

/**
* The values by which the data is currently sorted.
* A `undefined` column or a direction of "none" means no sorting is applied.
*/
export type SortState<TEntry extends DataGridEntry> = {
/**
* The column which is used to sort by.
* `undefined` means no sorting is applied.
*/
column: keyof TEntry | undefined;
/**
* The sorting direction by which the `column` is sorted.
* `none` means no sorting is applied.
*/
direction: SortDirection;
};

/**
* Per column sorting configuration.
* If at least one column has configuration, sorting must be explicitly enabled for all columns.
*/
export type SortColumnOptions<TEntry extends DataGridEntry> = {
[TKey in keyof TEntry]?: {
/**
* If sorting is enabled for this column.
*/
enabled: boolean;
/**
* A custom sorting function for this column.
* By default the `Intl.Collator` with the current locale is used.
*/
sortFunc?: Compare<TEntry[TKey]>;
};
};

/**
* The options of the sorting feature for the OnyxDataGrid component.
*/
export type SortOptions<TEntry extends DataGridEntry> = {
/**
* The currently applied sorting. Will be updated by the data grid, can be used for reading, updating and watching the applied sorting.
*/
sortState?: MaybeRef<SortState<TEntry>>;
/**
* The options for each column, including whether sorting is enabled and a custom sorting function. If undefined, sorting is enabled for all columns (default).
*/
columns?: MaybeRefOrGetter<SortColumnOptions<TEntry>>;
};
11 changes: 10 additions & 1 deletion packages/sit-onyx/src/components/OnyxDataGrid/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { DataGridFeature } from "./features";

export type DataGridMetadata = Record<string, unknown>;

/**
* @experimental The DataGrid is still working in progress and the props will change in the future.
*/
export type OnyxDataGridProps<TEntry extends DataGridEntry> = {
export type OnyxDataGridProps<
TEntry extends DataGridEntry = DataGridEntry,
TFeatures extends DataGridFeature<TEntry, symbol>[] = DataGridFeature<TEntry, symbol>[],
> = {
/**
* Features that should be applied.
*/
features?: TFeatures;
/**
* The order of and which columns should be rendered.
*/
Expand Down
Loading

0 comments on commit 744f82e

Please sign in to comment.