Best Practices
Table of Content
List of concerns
Hiding missing concepts
Any action that cannot be achieved given the current context must be hidden from the UI.
For instance, if the current backend does not support pages history, the notion of history must be hidden from all places.
In the same way, if a feature could be implemented but is currently missing (either not implemented or not loaded), the UI must adapt accordingly.
In summary, no part of the UI must expect a feature to be implemented at all time.
Loading elements
- Elements with an asynchronous resolution must display a loading UI as long as they are not fully loaded. This loading element must use the same vertical and horizontal space as the final content as long as possible. To prevent the UI from being jumping in unexpected ways once the final content is rendered.
- Any asynchronous task can fail. An error state must be anticipated and must present in a clear way what failed, why, and what the user can do to circumvent the issue.
Error reporting
- Error reporting must be handled in all cases where some part of the process can fail
- If the error needs to be presented to the user, it must be in a non-technical vocabulary, using localized strings
Single source of truth
An entity resolved from the backend must be fetched only once for the whole application.
This is important:
- in terms of network use, both for payload side but also for latency. Network traffic is something slow in regard to use experience.
2. in terms of content consistence. If the same data is fetched twice at different point in time, there is a risk of presented outdated and inconsistent information in the UI
Use of APIs
Everything must be encapsulated behind APIs, consequently:
- the APIs signatures must be defined and discussed
- implementations must be provided
- the application must anticipate missing implementation and gracefully fallback
Known pattern
Central store
Architecture
Explanations
A central store has several roles:
- provides the operations to interact with a given type of content
- provides the reactive references to the retrieved values
- call the correct APIs to execute the actions and update the internal store accordingly
- the store is also in charge of the internal consistency:
- no inconsistent state (e.g., an entity cannot be loading, and have an error state at the same time)
- no duplicate fetching, if two parts of the UI calls the same action concurrently, a single backend call must be performed
Example
To give a concrete example, let use the page attachments store.
A page has a list of attachments, and an attachments count.
Client can request for the list of attachments, the attachments count, or to upload a new attachment.
The list of attachments, or the attachments count, can be loading, or can fail.
Uploading a new attachment can be ongoing, and can fail.
When a new attachment is uploaded, the list of attachments is updated.
Implementation
We use pinia for the store implementation. Pinia allows for the definition of reactive stores, making it easier to provide and manipulate reactive references to the store data.
import { Store, StoreDefinition, defineStore, storeToRefs } from "pinia";
import { Ref } from "vue";
import type { CristalApp } from "@xwiki/cristal-api";
import type {
Attachment,
AttachmentsService,
} from "@xwiki/cristal-attachments-api";
// Unique store ID
type Id = "attachments";
// Definition of the internal store state
type State = {
// the list of attachments
attachments: Attachment[];
// the attachments count
count: number;
// the loading state
isLoading: boolean;
// the uploading new attachment state
isUploading: boolean;
// whether the request page exists
unknownPage: boolean;
//
error: string | undefined;
};
/**
* Take a given type "Type" and wraps each of its fields in a readonly Ref.
*/
type WrappedRefs<Type> = {
readonly [Property in keyof Type]: Ref<Type[Property]>;
};
type StateRefs = WrappedRefs<State>;
type Getters = Record<string, never>;
// List of internal store actions
type Actions = {
setLoading(): void;
/**
* Update the attachments of the store
* @param attachments - the list of attachments to store
* @param count - an optional count, used for the count status if available, otherwise the size of the attachment list is used
* @throws Error in case of upload error
*/
updateAttachments(
attachments: Attachment[] | undefined,
count?: number,
): void;
/**
* Set an attachment list error
* @param error the error message
*/
setError(error: string): void;
/**
* Start the attachment upload
*/
startUploading(): void;
/**
* Stop the attachment upload
*/
stopUploading(): void;
};
type AttachmentsStoreDefinition = StoreDefinition<Id, State, Getters, Actions>;
type AttachmentsStore = Store<Id, State, Getters, Actions>;
const attachmentsStore: AttachmentsStoreDefinition = defineStore<
Id,
State,
Getters,
Actions
>("attachments", {
state() {
return {
attachments: [],
count: 0,
isLoading: true,
isUploading: false,
error: undefined,
unknownPage: false,
};
},
actions: {
setLoading() {
this.isLoading = true;
},
updateAttachments(attachments, count?: number) {
this.isLoading = false;
this.error = undefined;
if (attachments) {
this.unknownPage = false;
this.attachments = attachments;
this.count = count || attachments.length;
} else {
this.unknownPage = true;
this.attachments = [];
}
},
setError(error: string) {
this.isLoading = false;
this.error = error;
},
startUploading() {
this.isUploading = true;
},
stopUploading() {
this.isUploading = false;
},
},
});
class DefaultAttachmentsService {
private readonly refs: StateRefs;
private readonly store: AttachmentsStore;
constructor(
@inject<CristalApp>("CristalApp") private readonly cristalApp: CristalApp,
) {
// An internal store is kept to easily provide refs for updatable elements.
this.store = attachmentsStore();
this.refs = storeToRefs(this.store);
}
// Each accessor return a reactive reference to an internal store state.
// It could also be a store getter if a transformation of the internal state is needed before being provided
list(): StateRefs["attachments"] {
return this.refs.attachments;
}
count(): StateRefs["count"] {
return this.refs.count;
}
isLoading(): StateRefs["isLoading"] {
return this.refs.isLoading;
}
isUploading(): StateRefs["isUploading"] {
return this.refs.isUploading;
}
getError(): StateRefs["error"] {
return this.refs.error;
}
async refresh(page: string): Promise<void> {
this.store.setLoading();
try {
const attachmentData = await this.getStorage().getAttachments(page);
if (attachmentData) {
const { attachments, count } = attachmentData;
this.store.updateAttachments(
attachments?.map(
({ id, reference, mimetype, href, date, size, author }) => {
let userDetails = undefined;
if (author) {
userDetails = { name: author };
}
return {
id,
name: reference,
mimetype,
href,
date,
size,
author: userDetails,
};
},
),
count,
);
}
} catch (e) {
if (e instanceof Error) {
this.store.setError(e.message);
}
}
}
private getStorage() {
return this.cristalApp.getWikiConfig().storage;
}
async upload(page: string, files: File[]): Promise<void> {
this.store.startUploading();
try {
await this.getStorage().saveAttachments(page, files);
} finally {
this.store.stopUploading();
}
await this.refresh(page); }
}

This project is being financed by the French State as part of the France 2030 program
Ce projet est financé par l’État Français dans le cadre de France 2030