Skip to content

[WC-2896] Gallery personalisation #1730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/pluggableWidgets/gallery-web/src/Gallery.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,37 @@
</property>
</propertyGroup>
</propertyGroup>
<propertyGroup caption="Personalization">
<propertyGroup caption="Configuration">
<property key="stateStorageType" type="enumeration" defaultValue="attribute">
<caption>Store configuration in</caption>
<description>When Browser local storage is selected, the configuration is scoped to a browser profile. This configuration is not tied to a Mendix user.</description>
<enumerationValues>
<enumerationValue key="attribute">Attribute</enumerationValue>
<enumerationValue key="localStorage">Browser local storage</enumerationValue>
</enumerationValues>
</property>
<property key="stateStorageAttr" type="attribute" required="false" onChange="onConfigurationChange">
<caption>Attribute</caption>
<description>Attribute containing the personalized configuration of the capabilities. This configuration is automatically stored and loaded. The attribute requires Unlimited String.</description>
<attributeTypes>
<attributeType name="String" />
</attributeTypes>
</property>
<property key="storeFilters" type="boolean" defaultValue="true">
<caption>Store filters</caption>
<description />
</property>
<property key="storeSort" type="boolean" defaultValue="true">
<caption>Store sort</caption>
<description />
</property>
<property key="onConfigurationChange" type="action" required="false">
<caption>On change</caption>
<description />
</property>
</propertyGroup>
</propertyGroup>
<propertyGroup caption="Accessibility">
<propertyGroup caption="Aria labels">
<property key="filterSectionTitle" type="textTemplate" required="false">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { PlainJs } from "@mendix/filter-commons/typings/settings";
import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate";
import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller";
import { EditableValue } from "mendix";
import { computed, makeObservable } from "mobx";
import { ObservableStorage } from "src/typings/storage";

type Gate = DerivedPropsGate<{
stateStorageAttr: EditableValue<string>;
}>;

export class AttributeStorage implements ObservableStorage, ReactiveController {
private readonly _gate: Gate;

constructor(host: ReactiveControllerHost, gate: Gate) {
host.addController(this);

this._gate = gate;
makeObservable<this, "_attribute">(this, {
_attribute: computed,
data: computed.struct
});
}

setup(): () => void {
return () => {};
}

private get _attribute(): EditableValue<string> {
return this._gate.props.stateStorageAttr;
}

get data(): PlainJs {
const jsonString = this._attribute.value;
if (!jsonString) {
return null;
}
try {
return JSON.parse(jsonString) as PlainJs;
} catch {
console.warn("Invalid JSON configuration in the attribute. Resetting configuration.");
this._attribute.setValue("");
return null;
}
}

setData(data: PlainJs): void {
data = data === "" ? null : data;
this._attribute.setValue(JSON.stringify(data));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PlainJs } from "@mendix/filter-commons/typings/settings";
import { ObservableStorage } from "src/typings/storage";

export class BrowserStorage implements ObservableStorage {
constructor(private readonly _storageKey: string) {}

get data(): PlainJs {
try {
return JSON.parse(localStorage.getItem(this._storageKey) ?? "null");
} catch {
return null;
}
}

setData(data: PlainJs): void {
localStorage.setItem(this._storageKey, JSON.stringify(data));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { PlainJs, Serializable } from "@mendix/filter-commons/typings/settings";
import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch";
import { ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller";
import { action, comparer, computed, makeObservable, reaction } from "mobx";
import { ObservableStorage } from "src/typings/storage";

interface GalleryPersistentStateControllerSpec {
filtersHost: Serializable;
sortHost: Serializable;
storage: ObservableStorage;
}

export class GalleryPersistentStateController {
private readonly _storage: ObservableStorage;
private readonly _filtersHost: Serializable;
private readonly _sortHost: Serializable;

readonly schemaVersion: number = 1;

constructor(host: ReactiveControllerHost, spec: GalleryPersistentStateControllerSpec) {
host.addController(this);
this._storage = spec.storage;
this._filtersHost = spec.filtersHost;
this._sortHost = spec.sortHost;

makeObservable<this, "_persistentState">(this, {
_persistentState: computed,
fromJSON: action
});
}

setup(): () => void {
const [add, disposeAll] = disposeBatch();

// Write state to storage
const clearWrite = reaction(
() => this._persistentState,
data => this._storage.setData(data),
{ delay: 250, equals: comparer.structural }
);

// Update state from storage
const clearRead = reaction(
() => this._storage.data,
data => {
if (data == null) {
return;
}
if (this._validate(data)) {
this.fromJSON(data);
} else {
console.warn("Invalid gallery settings. Reset storage to avoid conflicts.");
this._storage.setData(null);
}
},
{ fireImmediately: true, equals: comparer.structural }
);

add(clearWrite);
add(clearRead);

return disposeAll;
}

private get _persistentState(): PlainJs {
return this.toJSON();
}

private _validate(data: PlainJs): data is { [key: string]: PlainJs } {
if (data == null || typeof data !== "object" || !("version" in data) || data.version !== this.schemaVersion) {
return false;
}
return true;
}

fromJSON(data: PlainJs): void {
if (!this._validate(data)) {
return;
}
this._filtersHost.fromJSON(data.filters);
this._sortHost.fromJSON(data.sort);
}

toJSON(): PlainJs {
return {
version: 1,
filters: this._filtersHost.toJSON(),
sort: this._sortHost.toJSON()
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
import { SortAPI } from "@mendix/widget-plugin-sorting/react/context";
import { SortStoreHost } from "@mendix/widget-plugin-sorting/stores/SortStoreHost";
import { ListValue } from "mendix";
import { PaginationEnum } from "../../typings/GalleryProps";
import { EditableValue, ListValue } from "mendix";
import { AttributeStorage } from "src/stores/AttributeStorage";
import { BrowserStorage } from "src/stores/BrowserStorage";
import { GalleryPersistentStateController } from "src/stores/GalleryPersistentStateController";
import { ObservableStorage } from "src/typings/storage";
import { PaginationEnum, StateStorageTypeEnum } from "../../typings/GalleryProps";
import { QueryParamsController } from "../controllers/QueryParamsController";

interface DynamicProps {
datasource: ListValue;
stateStorageAttr?: EditableValue<string>;
}

interface StaticProps {
Expand All @@ -22,6 +27,9 @@ interface StaticProps {
showTotalCount: boolean;
pageSize: number;
name: string;
stateStorageType: StateStorageTypeEnum;
storeFilters: boolean;
storeSort: boolean;
}

export type GalleryPropsGate = DerivedPropsGate<DynamicProps>;
Expand All @@ -32,6 +40,9 @@ type GalleryStoreSpec = StaticProps & {

export class GalleryStore extends BaseControllerHost {
private readonly _query: DatasourceController;
private readonly _filtersHost: CustomFilterHost;
private readonly _sortHost: SortStoreHost;
private _storage: ObservableStorage | null = null;

readonly id: string = `GalleryStore@${generateUUID()}`;
readonly name: string;
Expand All @@ -54,26 +65,49 @@ export class GalleryStore extends BaseControllerHost {
showTotalCount: spec.showTotalCount
});

const filterObserver = new CustomFilterHost();
const sortObserver = new SortStoreHost();
this._filtersHost = new CustomFilterHost();
this._sortHost = new SortStoreHost();

const paramCtrl = new QueryParamsController(this, this._query, filterObserver, sortObserver);
const paramCtrl = new QueryParamsController(this, this._query, this._filtersHost, this._sortHost);

this.filterAPI = createContextWithStub({
filterObserver,
filterObserver: this._filtersHost,
parentChannelName: this.id,
sharedInitFilter: paramCtrl.unzipFilter(spec.gate.props.datasource.filter)
});

this.sortAPI = {
version: 1,
host: sortObserver,
host: this._sortHost,
initSortOrder: spec.gate.props.datasource.sortOrder
};

new RefreshController(this, {
delay: 0,
query: this._query.derivedQuery
});

this.initStateController(spec, spec.gate);
}

initStateController(props: StaticProps, gate: GalleryPropsGate): void {
if (props.stateStorageType === "localStorage") {
this._storage = new BrowserStorage(this.name);
} else if (gate.props.stateStorageAttr) {
this._storage = new AttributeStorage(
this,
gate as DerivedPropsGate<{ stateStorageAttr: EditableValue<string> }>
);
}

if (!this._storage) {
return;
}

new GalleryPersistentStateController(this, {
storage: this._storage,
filtersHost: this._filtersHost,
sortHost: this._sortHost
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PlainJs } from "@mendix/filter-commons/typings/settings";

export interface ObservableStorage {
data: PlainJs;
setData(data: PlainJs): void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @author Mendix Widgets Framework Team
*/
import { ComponentType, CSSProperties, ReactNode } from "react";
import { ActionValue, DynamicValue, ListValue, ListActionValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix";
import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix";

export type ItemSelectionModeEnum = "toggle" | "clear";

Expand All @@ -18,6 +18,8 @@ export type ShowEmptyPlaceholderEnum = "none" | "custom";

export type OnClickTriggerEnum = "single" | "double";

export type StateStorageTypeEnum = "attribute" | "localStorage";

export interface GalleryContainerProps {
name: string;
class: string;
Expand All @@ -42,6 +44,10 @@ export interface GalleryContainerProps {
onClickTrigger: OnClickTriggerEnum;
onClick?: ListActionValue;
onSelectionChange?: ActionValue;
stateStorageType: StateStorageTypeEnum;
stateStorageAttr?: EditableValue<string>;
storeFilters: boolean;
storeSort: boolean;
filterSectionTitle?: DynamicValue<string>;
emptyMessageTitle?: DynamicValue<string>;
ariaLabelListBox?: DynamicValue<string>;
Expand Down Expand Up @@ -78,6 +84,11 @@ export interface GalleryPreviewProps {
onClickTrigger: OnClickTriggerEnum;
onClick: {} | null;
onSelectionChange: {} | null;
stateStorageType: StateStorageTypeEnum;
stateStorageAttr: string;
storeFilters: boolean;
storeSort: boolean;
onConfigurationChange: {} | null;
filterSectionTitle: string;
emptyMessageTitle: string;
ariaLabelListBox: string;
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/filter-commons/src/typings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ export type SelectData = string[];
export type FilterData = InputData | SelectData | null | undefined;

export type FiltersSettingsMap<T> = Map<T, FilterData>;

export type PlainJs = string | number | boolean | null | PlainJs[] | { [key: string]: PlainJs };

export interface Serializable {
toJSON(): PlainJs;
fromJSON(data: PlainJs): void;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { tag } from "@mendix/filter-commons/condition-utils";
import { FiltersSettingsMap } from "@mendix/filter-commons/typings/settings";
import { FilterData, FiltersSettingsMap, PlainJs, Serializable } from "@mendix/filter-commons/typings/settings";
import { FilterCondition } from "mendix/filters";
import { and } from "mendix/filters/builders";
import { autorun, makeAutoObservable } from "mobx";
import { Filter, ObservableFilterHost } from "../../typings/ObservableFilterHost";

export class CustomFilterHost implements ObservableFilterHost {
export class CustomFilterHost implements ObservableFilterHost, Serializable {
private filters: Map<string, [store: Filter, dispose: () => void]> = new Map();
private settingsBuffer: FiltersSettingsMap<string> = new Map();

Expand Down Expand Up @@ -46,4 +46,16 @@ export class CustomFilterHost implements ObservableFilterHost {
this.filters.get(key)?.[1]();
}
}

toJSON(): PlainJs {
return [...this.settings.entries()] as PlainJs;
}

fromJSON(data: PlainJs): void {
if (data == null || !Array.isArray(data)) {
return;
}

this.settings = new Map(data as Array<[string, FilterData]>);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class StringInputFilterStore
return;
}
const [fn, s1, s2] = inputData;
this.setState([fn, s1 ? s1 : undefined, s2 ? s2 : undefined]);
this.setState([fn, s1 ?? undefined, s2 ?? undefined]);
this.isInitialized = true;
}

Expand Down
Loading
Loading