mirror of
https://github.com/flarum/core.git
synced 2025-07-16 22:31:18 +02:00
Merge pull request #3174 from flarum/as/models-typing
Typescript for models
This commit is contained in:
11
framework/core/js/src/@types/global.d.ts
vendored
11
framework/core/js/src/@types/global.d.ts
vendored
@@ -46,6 +46,17 @@ declare const app: never;
|
|||||||
declare const m: import('mithril').Static;
|
declare const m: import('mithril').Static;
|
||||||
declare const dayjs: typeof import('dayjs');
|
declare const dayjs: typeof import('dayjs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From https://github.com/lokesh/color-thief/issues/188
|
||||||
|
*/
|
||||||
|
declare module 'color-thief-browser' {
|
||||||
|
type Color = [number, number, number];
|
||||||
|
export default class ColorThief {
|
||||||
|
getColor: (img: HTMLImageElement | null) => Color;
|
||||||
|
getPalette: (img: HTMLImageElement | null) => Color[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ESModule = { __esModule: true; [key: string]: unknown };
|
type ESModule = { __esModule: true; [key: string]: unknown };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -44,9 +44,9 @@ export default class AdminApplication extends Application {
|
|||||||
history = {
|
history = {
|
||||||
canGoBack: () => true,
|
canGoBack: () => true,
|
||||||
getPrevious: () => {},
|
getPrevious: () => {},
|
||||||
backUrl: () => this.forum.attribute('baseUrl'),
|
backUrl: () => this.forum.attribute<string>('baseUrl'),
|
||||||
back: function () {
|
back: function () {
|
||||||
window.location = this.backUrl();
|
window.location.assign(this.backUrl());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import type Mithril from 'mithril';
|
||||||
|
|
||||||
import app from '../../admin/app';
|
import app from '../../admin/app';
|
||||||
|
|
||||||
import EditUserModal from '../../common/components/EditUserModal';
|
import EditUserModal from '../../common/components/EditUserModal';
|
||||||
@@ -14,7 +16,6 @@ import classList from '../../common/utils/classList';
|
|||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
|
|
||||||
import AdminPage from './AdminPage';
|
import AdminPage from './AdminPage';
|
||||||
import Mithril from 'mithril';
|
|
||||||
|
|
||||||
type ColumnData = {
|
type ColumnData = {
|
||||||
/**
|
/**
|
||||||
@@ -24,20 +25,9 @@ type ColumnData = {
|
|||||||
/**
|
/**
|
||||||
* Component(s) to show for this column.
|
* Component(s) to show for this column.
|
||||||
*/
|
*/
|
||||||
content: (user: User) => JSX.Element;
|
content: (user: User) => Mithril.Children;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApiPayload = {
|
|
||||||
data: Record<string, unknown>[];
|
|
||||||
included: Record<string, unknown>[];
|
|
||||||
links: {
|
|
||||||
first: string;
|
|
||||||
next?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type UsersApiResponse = User[] & { payload: ApiPayload };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin page which displays a paginated list of all users on the forum.
|
* Admin page which displays a paginated list of all users on the forum.
|
||||||
*/
|
*/
|
||||||
@@ -185,7 +175,7 @@ export default class UserListPage extends AdminPage {
|
|||||||
'id',
|
'id',
|
||||||
{
|
{
|
||||||
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
|
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
|
||||||
content: (user: User) => user.id(),
|
content: (user: User) => user.id() ?? '',
|
||||||
},
|
},
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
@@ -348,15 +338,15 @@ export default class UserListPage extends AdminPage {
|
|||||||
if (pageNumber < 0) pageNumber = 0;
|
if (pageNumber < 0) pageNumber = 0;
|
||||||
|
|
||||||
app.store
|
app.store
|
||||||
.find('users', {
|
.find<User[]>('users', {
|
||||||
page: {
|
page: {
|
||||||
limit: this.numPerPage,
|
limit: this.numPerPage,
|
||||||
offset: pageNumber * this.numPerPage,
|
offset: pageNumber * this.numPerPage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((apiData: UsersApiResponse) => {
|
.then((apiData) => {
|
||||||
// Next link won't be present if there's no more data
|
// Next link won't be present if there's no more data
|
||||||
this.moreData = !!apiData.payload.links.next;
|
this.moreData = !!apiData.payload?.links?.next;
|
||||||
|
|
||||||
let data = apiData;
|
let data = apiData;
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ import ModalManager from './components/ModalManager';
|
|||||||
import AlertManager from './components/AlertManager';
|
import AlertManager from './components/AlertManager';
|
||||||
import RequestErrorModal from './components/RequestErrorModal';
|
import RequestErrorModal from './components/RequestErrorModal';
|
||||||
import Translator from './Translator';
|
import Translator from './Translator';
|
||||||
import Store from './Store';
|
import Store, { ApiPayload, ApiResponse, ApiResponsePlural, ApiResponseSingle, payloadIsPlural } from './Store';
|
||||||
import Session from './Session';
|
import Session from './Session';
|
||||||
import extract from './utils/extract';
|
import extract from './utils/extract';
|
||||||
import Drawer from './utils/Drawer';
|
import Drawer from './utils/Drawer';
|
||||||
@@ -31,6 +31,7 @@ import type DefaultResolver from './resolvers/DefaultResolver';
|
|||||||
import type Mithril from 'mithril';
|
import type Mithril from 'mithril';
|
||||||
import type Component from './Component';
|
import type Component from './Component';
|
||||||
import type { ComponentAttrs } from './Component';
|
import type { ComponentAttrs } from './Component';
|
||||||
|
import Model, { SavedModelData } from './Model';
|
||||||
|
|
||||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||||
|
|
||||||
@@ -210,10 +211,10 @@ export default class Application {
|
|||||||
drawer!: Drawer;
|
drawer!: Drawer;
|
||||||
|
|
||||||
data!: {
|
data!: {
|
||||||
apiDocument: Record<string, unknown> | null;
|
apiDocument: ApiPayload | null;
|
||||||
locale: string;
|
locale: string;
|
||||||
locales: Record<string, string>;
|
locales: Record<string, string>;
|
||||||
resources: Record<string, unknown>[];
|
resources: SavedModelData[];
|
||||||
session: { userId: number; csrfToken: string };
|
session: { userId: number; csrfToken: string };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
@@ -255,9 +256,9 @@ export default class Application {
|
|||||||
|
|
||||||
this.store.pushPayload({ data: this.data.resources });
|
this.store.pushPayload({ data: this.data.resources });
|
||||||
|
|
||||||
this.forum = this.store.getById('forums', 1);
|
this.forum = this.store.getById('forums', '1')!;
|
||||||
|
|
||||||
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
|
this.session = new Session(this.store.getById<User>('users', String(this.data.session.userId)) ?? null, this.data.session.csrfToken);
|
||||||
|
|
||||||
this.mount();
|
this.mount();
|
||||||
|
|
||||||
@@ -317,10 +318,14 @@ export default class Application {
|
|||||||
/**
|
/**
|
||||||
* Get the API response document that has been preloaded into the application.
|
* Get the API response document that has been preloaded into the application.
|
||||||
*/
|
*/
|
||||||
preloadedApiDocument(): Record<string, unknown> | null {
|
preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | null;
|
||||||
|
preloadedApiDocument<Ms extends Model[]>(): ApiResponsePlural<Ms[number]> | null;
|
||||||
|
preloadedApiDocument<M extends Model | Model[]>(): ApiResponse<FlatArray<M, 1>> | null {
|
||||||
// If the URL has changed, the preloaded Api document is invalid.
|
// If the URL has changed, the preloaded Api document is invalid.
|
||||||
if (this.data.apiDocument && window.location.href === this.initialRoute) {
|
if (this.data.apiDocument && window.location.href === this.initialRoute) {
|
||||||
const results = this.store.pushPayload(this.data.apiDocument);
|
const results = payloadIsPlural(this.data.apiDocument)
|
||||||
|
? this.store.pushPayload<FlatArray<M, 1>[]>(this.data.apiDocument)
|
||||||
|
: this.store.pushPayload<FlatArray<M, 1>>(this.data.apiDocument);
|
||||||
|
|
||||||
this.data.apiDocument = null;
|
this.data.apiDocument = null;
|
||||||
|
|
||||||
@@ -450,7 +455,7 @@ export default class Application {
|
|||||||
* @param options
|
* @param options
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
|
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType> {
|
||||||
const options = this.transformRequestOptions(originalOptions);
|
const options = this.transformRequestOptions(originalOptions);
|
||||||
|
|
||||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||||
|
@@ -1,323 +0,0 @@
|
|||||||
import app from '../common/app';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `Model` class represents a local data resource. It provides methods to
|
|
||||||
* persist changes via the API.
|
|
||||||
*
|
|
||||||
* @abstract
|
|
||||||
*/
|
|
||||||
export default class Model {
|
|
||||||
/**
|
|
||||||
* @param {Object} data A resource object from the API.
|
|
||||||
* @param {Store} store The data store that this model should be persisted to.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
constructor(data = {}, store = null) {
|
|
||||||
/**
|
|
||||||
* The resource object from the API.
|
|
||||||
*
|
|
||||||
* @type {Object}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
this.data = data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The time at which the model's data was last updated. Watching the value
|
|
||||||
* of this property is a fast way to retain/cache a subtree if data hasn't
|
|
||||||
* changed.
|
|
||||||
*
|
|
||||||
* @type {Date}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
this.freshness = new Date();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether or not the resource exists on the server.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
this.exists = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The data store that this resource should be persisted to.
|
|
||||||
*
|
|
||||||
* @type {Store}
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
this.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the model's ID.
|
|
||||||
*
|
|
||||||
* @return {Integer}
|
|
||||||
* @public
|
|
||||||
* @final
|
|
||||||
*/
|
|
||||||
id() {
|
|
||||||
return this.data.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get one of the model's attributes.
|
|
||||||
*
|
|
||||||
* @param {String} attribute
|
|
||||||
* @return {*}
|
|
||||||
* @public
|
|
||||||
* @final
|
|
||||||
*/
|
|
||||||
attribute(attribute) {
|
|
||||||
return this.data.attributes[attribute];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge new data into this model locally.
|
|
||||||
*
|
|
||||||
* @param {Object} data A resource object to merge into this model
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
pushData(data) {
|
|
||||||
// Since most of the top-level items in a resource object are objects
|
|
||||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
|
||||||
// merge at the second level if that's the case.
|
|
||||||
for (const key in data) {
|
|
||||||
if (typeof data[key] === 'object') {
|
|
||||||
this.data[key] = this.data[key] || {};
|
|
||||||
|
|
||||||
// For every item in a second-level object, we want to check if we've
|
|
||||||
// been handed a Model instance. If so, we will convert it to a
|
|
||||||
// relationship data object.
|
|
||||||
for (const innerKey in data[key]) {
|
|
||||||
if (data[key][innerKey] instanceof Model) {
|
|
||||||
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
|
|
||||||
}
|
|
||||||
this.data[key][innerKey] = data[key][innerKey];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.data[key] = data[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now that we've updated the data, we can say that the model is fresh.
|
|
||||||
// This is an easy way to invalidate retained subtrees etc.
|
|
||||||
this.freshness = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge new attributes into this model locally.
|
|
||||||
*
|
|
||||||
* @param {Object} attributes The attributes to merge.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
pushAttributes(attributes) {
|
|
||||||
this.pushData({ attributes });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge new attributes into this model, both locally and with persistence.
|
|
||||||
*
|
|
||||||
* @param {Object} attributes The attributes to save. If a 'relationships' key
|
|
||||||
* exists, it will be extracted and relationships will also be saved.
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @return {Promise}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
save(attributes, options = {}) {
|
|
||||||
const data = {
|
|
||||||
type: this.data.type,
|
|
||||||
id: this.data.id,
|
|
||||||
attributes,
|
|
||||||
};
|
|
||||||
|
|
||||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
|
||||||
// set it on the top-level data object instead. We will be sending this data
|
|
||||||
// object to the API for persistence.
|
|
||||||
if (attributes.relationships) {
|
|
||||||
data.relationships = {};
|
|
||||||
|
|
||||||
for (const key in attributes.relationships) {
|
|
||||||
const model = attributes.relationships[key];
|
|
||||||
|
|
||||||
data.relationships[key] = {
|
|
||||||
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
delete attributes.relationships;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before we update the model's data, we should make a copy of the model's
|
|
||||||
// old data so that we can revert back to it if something goes awry during
|
|
||||||
// persistence.
|
|
||||||
const oldData = this.copyData();
|
|
||||||
|
|
||||||
this.pushData(data);
|
|
||||||
|
|
||||||
const request = { data };
|
|
||||||
if (options.meta) request.meta = options.meta;
|
|
||||||
|
|
||||||
return app
|
|
||||||
.request(
|
|
||||||
Object.assign(
|
|
||||||
{
|
|
||||||
method: this.exists ? 'PATCH' : 'POST',
|
|
||||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
|
||||||
body: request,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
// If everything went well, we'll make sure the store knows that this
|
|
||||||
// model exists now (if it didn't already), and we'll push the data that
|
|
||||||
// the API returned into the store.
|
|
||||||
(payload) => {
|
|
||||||
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
|
|
||||||
this.store.data[payload.data.type][payload.data.id] = this;
|
|
||||||
return this.store.pushPayload(payload);
|
|
||||||
},
|
|
||||||
|
|
||||||
// If something went wrong, though... good thing we backed up our model's
|
|
||||||
// old data! We'll revert to that and let others handle the error.
|
|
||||||
(response) => {
|
|
||||||
this.pushData(oldData);
|
|
||||||
m.redraw();
|
|
||||||
throw response;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a request to delete the resource.
|
|
||||||
*
|
|
||||||
* @param {Object} body Data to send along with the DELETE request.
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @return {Promise}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
delete(body, options = {}) {
|
|
||||||
if (!this.exists) return Promise.resolve();
|
|
||||||
|
|
||||||
return app
|
|
||||||
.request(
|
|
||||||
Object.assign(
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
this.exists = false;
|
|
||||||
this.store.remove(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct a path to the API endpoint for this resource.
|
|
||||||
*
|
|
||||||
* @return {String}
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
apiEndpoint() {
|
|
||||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
copyData() {
|
|
||||||
return JSON.parse(JSON.stringify(this.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a function which returns the value of the given attribute.
|
|
||||||
*
|
|
||||||
* @param {String} name
|
|
||||||
* @param {function} [transform] A function to transform the attribute value
|
|
||||||
* @return {*}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
static attribute(name, transform) {
|
|
||||||
return function () {
|
|
||||||
const value = this.data.attributes && this.data.attributes[name];
|
|
||||||
|
|
||||||
return transform ? transform(value) : value;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a function which returns the value of the given has-one
|
|
||||||
* relationship.
|
|
||||||
*
|
|
||||||
* @param {String} name
|
|
||||||
* @return {Model|Boolean|undefined} false if no information about the
|
|
||||||
* relationship exists; undefined if the relationship exists but the model
|
|
||||||
* has not been loaded; or the model if it has been loaded.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
static hasOne(name) {
|
|
||||||
return function () {
|
|
||||||
if (this.data.relationships) {
|
|
||||||
const relationship = this.data.relationships[name];
|
|
||||||
|
|
||||||
if (relationship) {
|
|
||||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a function which returns the value of the given has-many
|
|
||||||
* relationship.
|
|
||||||
*
|
|
||||||
* @param {String} name
|
|
||||||
* @return {Array|Boolean} false if no information about the relationship
|
|
||||||
* exists; an array if it does, containing models if they have been
|
|
||||||
* loaded, and undefined for those that have not.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
static hasMany(name) {
|
|
||||||
return function () {
|
|
||||||
if (this.data.relationships) {
|
|
||||||
const relationship = this.data.relationships[name];
|
|
||||||
|
|
||||||
if (relationship) {
|
|
||||||
return relationship.data.map((data) => app.store.getById(data.type, data.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform the given value into a Date object.
|
|
||||||
*
|
|
||||||
* @param {String} value
|
|
||||||
* @return {Date|null}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
static transformDate(value) {
|
|
||||||
return value ? new Date(value) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a resource identifier object for the given model.
|
|
||||||
*
|
|
||||||
* @param {Model} model
|
|
||||||
* @return {Object}
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
static getIdentifier(model) {
|
|
||||||
if (!model) return model;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: model.data.type,
|
|
||||||
id: model.data.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
363
framework/core/js/src/common/Model.ts
Normal file
363
framework/core/js/src/common/Model.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import app from '../common/app';
|
||||||
|
import { FlarumRequestOptions } from './Application';
|
||||||
|
import Store, { ApiPayloadSingle, ApiResponseSingle, MetaInformation } from './Store';
|
||||||
|
|
||||||
|
export interface ModelIdentifier {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelAttributes {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRelationships {
|
||||||
|
[relationship: string]: {
|
||||||
|
data: ModelIdentifier | ModelIdentifier[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnsavedModelData {
|
||||||
|
type?: string;
|
||||||
|
attributes?: ModelAttributes;
|
||||||
|
relationships?: ModelRelationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedModelData {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
attributes?: ModelAttributes;
|
||||||
|
relationships?: ModelRelationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelData = UnsavedModelData | SavedModelData;
|
||||||
|
|
||||||
|
interface SaveRelationships {
|
||||||
|
[relationship: string]: Model | Model[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveAttributes {
|
||||||
|
[key: string]: unknown;
|
||||||
|
relationships?: SaveRelationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Model` class represents a local data resource. It provides methods to
|
||||||
|
* persist changes via the API.
|
||||||
|
*/
|
||||||
|
export default abstract class Model {
|
||||||
|
/**
|
||||||
|
* The resource object from the API.
|
||||||
|
*/
|
||||||
|
data: ModelData = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time at which the model's data was last updated. Watching the value
|
||||||
|
* of this property is a fast way to retain/cache a subtree if data hasn't
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
freshness: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the resource exists on the server.
|
||||||
|
*/
|
||||||
|
exists: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data store that this resource should be persisted to.
|
||||||
|
*/
|
||||||
|
protected store: Store;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param data A resource object from the API.
|
||||||
|
* @param store The data store that this model should be persisted to.
|
||||||
|
*/
|
||||||
|
constructor(data: ModelData = {}, store = app.store) {
|
||||||
|
this.data = data;
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the model's ID.
|
||||||
|
*
|
||||||
|
* @final
|
||||||
|
*/
|
||||||
|
id(): string | undefined {
|
||||||
|
return 'id' in this.data ? this.data.id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get one of the model's attributes.
|
||||||
|
*
|
||||||
|
* @final
|
||||||
|
*/
|
||||||
|
attribute<T = unknown>(attribute: string): T {
|
||||||
|
return this.data?.attributes?.[attribute] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge new data into this model locally.
|
||||||
|
*
|
||||||
|
* @param data A resource object to merge into this model
|
||||||
|
*/
|
||||||
|
pushData(data: ModelData | { relationships?: SaveRelationships }): this {
|
||||||
|
if ('id' in data) {
|
||||||
|
(this.data as SavedModelData).id = data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('type' in data) {
|
||||||
|
this.data.type = data.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('attributes' in data) {
|
||||||
|
Object.assign(this.data.attributes, data.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('relationships' in data) {
|
||||||
|
const relationships = this.data.relationships ?? {};
|
||||||
|
|
||||||
|
// For every relationship field, we need to check if we've
|
||||||
|
// been handed a Model instance. If so, we will convert it to a
|
||||||
|
// relationship data object.
|
||||||
|
for (const r in data.relationships) {
|
||||||
|
const relationship = data.relationships[r];
|
||||||
|
|
||||||
|
let identifier: ModelRelationships[string];
|
||||||
|
if (relationship instanceof Model) {
|
||||||
|
identifier = { data: Model.getIdentifier(relationship) };
|
||||||
|
} else if (relationship instanceof Array) {
|
||||||
|
identifier = { data: relationship.map(Model.getIdentifier) };
|
||||||
|
} else {
|
||||||
|
identifier = relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.relationships[r] = identifier;
|
||||||
|
relationships[r] = identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.relationships = relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we've updated the data, we can say that the model is fresh.
|
||||||
|
// This is an easy way to invalidate retained subtrees etc.
|
||||||
|
this.freshness = new Date();
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge new attributes into this model locally.
|
||||||
|
*
|
||||||
|
* @param attributes The attributes to merge.
|
||||||
|
*/
|
||||||
|
pushAttributes(attributes: ModelAttributes) {
|
||||||
|
this.pushData({ attributes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge new attributes into this model, both locally and with persistence.
|
||||||
|
*
|
||||||
|
* @param attributes The attributes to save. If a 'relationships' key
|
||||||
|
* exists, it will be extracted and relationships will also be saved.
|
||||||
|
*/
|
||||||
|
save(
|
||||||
|
attributes: SaveAttributes,
|
||||||
|
options: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & { meta?: MetaInformation } = {}
|
||||||
|
): Promise<ApiResponseSingle<this>> {
|
||||||
|
const data: ModelData & { id?: string } = {
|
||||||
|
type: this.data.type,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('id' in this.data) {
|
||||||
|
data.id = this.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||||
|
// set it on the top-level data object instead. We will be sending this data
|
||||||
|
// object to the API for persistence.
|
||||||
|
if (attributes.relationships) {
|
||||||
|
data.relationships = {};
|
||||||
|
|
||||||
|
for (const key in attributes.relationships) {
|
||||||
|
const model = attributes.relationships[key];
|
||||||
|
|
||||||
|
data.relationships[key] = {
|
||||||
|
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
delete attributes.relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before we update the model's data, we should make a copy of the model's
|
||||||
|
// old data so that we can revert back to it if something goes awry during
|
||||||
|
// persistence.
|
||||||
|
const oldData = this.copyData();
|
||||||
|
|
||||||
|
this.pushData(data);
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
data,
|
||||||
|
meta: options.meta || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return app
|
||||||
|
.request<ApiPayloadSingle>({
|
||||||
|
method: this.exists ? 'PATCH' : 'POST',
|
||||||
|
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||||
|
body: request,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
// If everything went well, we'll make sure the store knows that this
|
||||||
|
// model exists now (if it didn't already), and we'll push the data that
|
||||||
|
// the API returned into the store.
|
||||||
|
(payload) => {
|
||||||
|
return this.store.pushPayload<this>(payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
// If something went wrong, though... good thing we backed up our model's
|
||||||
|
// old data! We'll revert to that and let others handle the error.
|
||||||
|
(err: Error) => {
|
||||||
|
this.pushData(oldData);
|
||||||
|
m.redraw();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to delete the resource.
|
||||||
|
*
|
||||||
|
* @param body Data to send along with the DELETE request.
|
||||||
|
*/
|
||||||
|
delete(body: FlarumRequestOptions<void>['body'] = {}, options: Omit<FlarumRequestOptions<void>, 'url'> = {}): Promise<void> {
|
||||||
|
if (!this.exists) return Promise.resolve();
|
||||||
|
|
||||||
|
return app
|
||||||
|
.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||||
|
body,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.exists = false;
|
||||||
|
|
||||||
|
this.store.remove(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a path to the API endpoint for this resource.
|
||||||
|
*/
|
||||||
|
protected apiEndpoint(): string {
|
||||||
|
return '/' + this.data.type + ('id' in this.data ? '/' + this.data.id : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected copyData(): ModelData {
|
||||||
|
return JSON.parse(JSON.stringify(this.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected rawRelationship<M extends Model>(relationship: string): undefined | ModelIdentifier;
|
||||||
|
protected rawRelationship<M extends Model[]>(relationship: string): undefined | ModelIdentifier[];
|
||||||
|
protected rawRelationship<_M extends Model | Model[]>(relationship: string): undefined | ModelIdentifier | ModelIdentifier[] {
|
||||||
|
return this.data.relationships?.[relationship]?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a function which returns the value of the given attribute.
|
||||||
|
*
|
||||||
|
* @param transform A function to transform the attribute value
|
||||||
|
*/
|
||||||
|
static attribute<T>(name: string): () => T;
|
||||||
|
static attribute<T, O = unknown>(name: string, transform: (attr: O) => T): () => T;
|
||||||
|
static attribute<T, O = unknown>(name: string, transform?: (attr: O) => T): () => T {
|
||||||
|
return function (this: Model) {
|
||||||
|
if (transform) {
|
||||||
|
return transform(this.attribute(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.attribute(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a function which returns the value of the given has-one
|
||||||
|
* relationship.
|
||||||
|
*
|
||||||
|
* @return false if no information about the
|
||||||
|
* relationship exists; undefined if the relationship exists but the model
|
||||||
|
* has not been loaded; or the model if it has been loaded.
|
||||||
|
*/
|
||||||
|
static hasOne<M extends Model>(name: string): () => M | false;
|
||||||
|
static hasOne<M extends Model | null>(name: string): () => M | null | false;
|
||||||
|
static hasOne<M extends Model>(name: string): () => M | false {
|
||||||
|
return function (this: Model) {
|
||||||
|
if (this.data.relationships) {
|
||||||
|
const relationshipData = this.data.relationships[name]?.data;
|
||||||
|
|
||||||
|
if (relationshipData instanceof Array) {
|
||||||
|
throw new Error(`Relationship ${name} on model ${this.data.type} is plural, so the hasOne method cannot be used to access it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationshipData) {
|
||||||
|
return this.store.getById<M>(relationshipData.type, relationshipData.id) as M;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a function which returns the value of the given has-many
|
||||||
|
* relationship.
|
||||||
|
*
|
||||||
|
* @return false if no information about the relationship
|
||||||
|
* exists; an array if it does, containing models if they have been
|
||||||
|
* loaded, and undefined for those that have not.
|
||||||
|
*/
|
||||||
|
static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false {
|
||||||
|
return function (this: Model) {
|
||||||
|
if (this.data.relationships) {
|
||||||
|
const relationshipData = this.data.relationships[name]?.data;
|
||||||
|
|
||||||
|
if (!(relationshipData instanceof Array)) {
|
||||||
|
throw new Error(`Relationship ${name} on model ${this.data.type} is singular, so the hasMany method cannot be used to access it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationshipData) {
|
||||||
|
return relationshipData.map((data) => this.store.getById<M>(data.type, data.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the given value into a Date object.
|
||||||
|
*/
|
||||||
|
static transformDate(value: string): Date;
|
||||||
|
static transformDate(value: string | null): Date | null;
|
||||||
|
static transformDate(value: string | undefined): Date | undefined;
|
||||||
|
static transformDate(value: string | null | undefined): Date | null | undefined;
|
||||||
|
static transformDate(value: string | null | undefined): Date | null | undefined {
|
||||||
|
return value != null ? new Date(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a resource identifier object for the given model.
|
||||||
|
*/
|
||||||
|
protected static getIdentifier(model: Model): ModelIdentifier;
|
||||||
|
protected static getIdentifier(model?: Model): ModelIdentifier | null {
|
||||||
|
if (!model || !('id' in model.data)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: model.data.type,
|
||||||
|
id: model.data.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -1,171 +0,0 @@
|
|||||||
import app from '../common/app';
|
|
||||||
/**
|
|
||||||
* The `Store` class defines a local data store, and provides methods to
|
|
||||||
* retrieve data from the API.
|
|
||||||
*/
|
|
||||||
export default class Store {
|
|
||||||
constructor(models) {
|
|
||||||
/**
|
|
||||||
* The local data store. A tree of resource types to IDs, such that
|
|
||||||
* accessing data[type][id] will return the model for that type/ID.
|
|
||||||
*
|
|
||||||
* @type {Object}
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
this.data = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model registry. A map of resource types to the model class that
|
|
||||||
* should be used to represent resources of that type.
|
|
||||||
*
|
|
||||||
* @type {Object}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
this.models = models;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push resources contained within an API payload into the store.
|
|
||||||
*
|
|
||||||
* @param {Object} payload
|
|
||||||
* @return {Model|Model[]} The model(s) representing the resource(s) contained
|
|
||||||
* within the 'data' key of the payload.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
pushPayload(payload) {
|
|
||||||
if (payload.included) payload.included.map(this.pushObject.bind(this));
|
|
||||||
|
|
||||||
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
|
|
||||||
|
|
||||||
// Attach the original payload to the model that we give back. This is
|
|
||||||
// useful to consumers as it allows them to access meta information
|
|
||||||
// associated with their request.
|
|
||||||
result.payload = payload;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a model to represent a resource object (or update an existing one),
|
|
||||||
* and push it into the store.
|
|
||||||
*
|
|
||||||
* @param {Object} data The resource object
|
|
||||||
* @return {Model|null} The model, or null if no model class has been
|
|
||||||
* registered for this resource type.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
pushObject(data) {
|
|
||||||
if (!this.models[data.type]) return null;
|
|
||||||
|
|
||||||
const type = (this.data[data.type] = this.data[data.type] || {});
|
|
||||||
|
|
||||||
if (type[data.id]) {
|
|
||||||
type[data.id].pushData(data);
|
|
||||||
} else {
|
|
||||||
type[data.id] = this.createRecord(data.type, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
type[data.id].exists = true;
|
|
||||||
|
|
||||||
return type[data.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a request to the API to find record(s) of a specific type.
|
|
||||||
*
|
|
||||||
* @param {String} type The resource type.
|
|
||||||
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
|
|
||||||
* Alternatively, if an object is passed, it will be handled as the
|
|
||||||
* `query` parameter.
|
|
||||||
* @param {Object} [query]
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @return {Promise}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
find(type, id, query = {}, options = {}) {
|
|
||||||
let params = query;
|
|
||||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
|
||||||
|
|
||||||
if (id instanceof Array) {
|
|
||||||
url += '?filter[id]=' + id.join(',');
|
|
||||||
} else if (typeof id === 'object') {
|
|
||||||
params = id;
|
|
||||||
} else if (id) {
|
|
||||||
url += '/' + id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app
|
|
||||||
.request(
|
|
||||||
Object.assign(
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
url,
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(this.pushPayload.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a record from the store by ID.
|
|
||||||
*
|
|
||||||
* @param {String} type The resource type.
|
|
||||||
* @param {Integer} id The resource ID.
|
|
||||||
* @return {Model}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
getById(type, id) {
|
|
||||||
return this.data[type] && this.data[type][id];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a record from the store by the value of a model attribute.
|
|
||||||
*
|
|
||||||
* @param {String} type The resource type.
|
|
||||||
* @param {String} key The name of the method on the model.
|
|
||||||
* @param {*} value The value of the model attribute.
|
|
||||||
* @return {Model}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
getBy(type, key, value) {
|
|
||||||
return this.all(type).filter((model) => model[key]() === value)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all loaded records of a specific type.
|
|
||||||
*
|
|
||||||
* @param {String} type
|
|
||||||
* @return {Model[]}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
all(type) {
|
|
||||||
const records = this.data[type];
|
|
||||||
|
|
||||||
return records ? Object.keys(records).map((id) => records[id]) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the given model from the store.
|
|
||||||
*
|
|
||||||
* @param {Model} model
|
|
||||||
*/
|
|
||||||
remove(model) {
|
|
||||||
delete this.data[model.data.type][model.id()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new record of the given type.
|
|
||||||
*
|
|
||||||
* @param {String} type The resource type
|
|
||||||
* @param {Object} [data] Any data to initialize the model with
|
|
||||||
* @return {Model}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
createRecord(type, data = {}) {
|
|
||||||
data.type = data.type || type;
|
|
||||||
|
|
||||||
return new this.models[type](data, this);
|
|
||||||
}
|
|
||||||
}
|
|
241
framework/core/js/src/common/Store.ts
Normal file
241
framework/core/js/src/common/Store.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import app from '../common/app';
|
||||||
|
import { FlarumRequestOptions } from './Application';
|
||||||
|
import Model, { ModelData, SavedModelData } from './Model';
|
||||||
|
|
||||||
|
export interface MetaInformation {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiQueryParamsSingle {
|
||||||
|
fields?: string[];
|
||||||
|
include?: string;
|
||||||
|
bySlug?: boolean;
|
||||||
|
meta?: MetaInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiQueryParamsPlural {
|
||||||
|
fields?: string[];
|
||||||
|
include?: string;
|
||||||
|
filter?: {
|
||||||
|
q: string;
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
page?: {
|
||||||
|
offset?: number;
|
||||||
|
number?: number;
|
||||||
|
limit?: number;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
sort?: string;
|
||||||
|
meta?: MetaInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle;
|
||||||
|
|
||||||
|
export interface ApiPayloadSingle {
|
||||||
|
data: SavedModelData;
|
||||||
|
included?: SavedModelData[];
|
||||||
|
meta?: MetaInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPayloadPlural {
|
||||||
|
data: SavedModelData[];
|
||||||
|
included?: SavedModelData[];
|
||||||
|
links?: {
|
||||||
|
first: string;
|
||||||
|
next?: string;
|
||||||
|
prev?: string;
|
||||||
|
};
|
||||||
|
meta?: MetaInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiPayload = ApiPayloadSingle | ApiPayloadPlural;
|
||||||
|
|
||||||
|
export type ApiResponseSingle<M extends Model> = M & { payload: ApiPayloadSingle };
|
||||||
|
export type ApiResponsePlural<M extends Model> = M[] & { payload: ApiPayloadPlural };
|
||||||
|
export type ApiResponse<M extends Model> = ApiResponseSingle<M> | ApiResponsePlural<M>;
|
||||||
|
|
||||||
|
interface ApiQueryRequestOptions<ResponseType> extends Omit<FlarumRequestOptions<ResponseType>, 'url'> {}
|
||||||
|
|
||||||
|
interface StoreData {
|
||||||
|
[type: string]: Partial<Record<string, Model>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payloadIsPlural(payload: ApiPayload): payload is ApiPayloadPlural {
|
||||||
|
return Array.isArray((payload as ApiPayloadPlural).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Store` class defines a local data store, and provides methods to
|
||||||
|
* retrieve data from the API.
|
||||||
|
*/
|
||||||
|
export default class Store {
|
||||||
|
/**
|
||||||
|
* The local data store. A tree of resource types to IDs, such that
|
||||||
|
* accessing data[type][id] will return the model for that type/ID.
|
||||||
|
*/
|
||||||
|
protected data: StoreData = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model registry. A map of resource types to the model class that
|
||||||
|
* should be used to represent resources of that type.
|
||||||
|
*/
|
||||||
|
models: Record<string, typeof Model>;
|
||||||
|
|
||||||
|
constructor(models: Record<string, typeof Model>) {
|
||||||
|
this.models = models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push resources contained within an API payload into the store.
|
||||||
|
*
|
||||||
|
* @return The model(s) representing the resource(s) contained
|
||||||
|
* within the 'data' key of the payload.
|
||||||
|
*/
|
||||||
|
pushPayload<M extends Model>(payload: ApiPayloadSingle): ApiResponseSingle<M>;
|
||||||
|
pushPayload<Ms extends Model[]>(payload: ApiPayloadPlural): ApiResponseSingle<Ms[number]>;
|
||||||
|
pushPayload<M extends Model | Model[]>(payload: ApiPayload): ApiResponse<FlatArray<M, 1>> {
|
||||||
|
if (payload.included) payload.included.map(this.pushObject.bind(this));
|
||||||
|
|
||||||
|
const models = payload.data instanceof Array ? payload.data.map((o) => this.pushObject(o, false)) : this.pushObject(payload.data, false);
|
||||||
|
const result = models as ApiResponse<FlatArray<M, 1>>;
|
||||||
|
|
||||||
|
// Attach the original payload to the model that we give back. This is
|
||||||
|
// useful to consumers as it allows them to access meta information
|
||||||
|
// associated with their request.
|
||||||
|
result.payload = payload;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a model to represent a resource object (or update an existing one),
|
||||||
|
* and push it into the store.
|
||||||
|
*
|
||||||
|
* @param data The resource object
|
||||||
|
* @return The model, or null if no model class has been
|
||||||
|
* registered for this resource type.
|
||||||
|
*/
|
||||||
|
pushObject<M extends Model>(data: SavedModelData): M | null;
|
||||||
|
pushObject<M extends Model>(data: SavedModelData, allowUnregistered: false): M;
|
||||||
|
pushObject<M extends Model>(data: SavedModelData, allowUnregistered = true): M | null {
|
||||||
|
if (!this.models[data.type]) {
|
||||||
|
if (allowUnregistered) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Cannot push object of type ${data.type}, as that type has not yet been registered in the store.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = (this.data[data.type] = this.data[data.type] || {});
|
||||||
|
|
||||||
|
// Necessary for TS to narrow correctly.
|
||||||
|
const curr = type[data.id] as M;
|
||||||
|
const instance = curr ? curr.pushData(data) : this.createRecord<M>(data.type, data);
|
||||||
|
|
||||||
|
type[data.id] = instance;
|
||||||
|
instance.exists = true;
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a request to the API to find record(s) of a specific type.
|
||||||
|
*/
|
||||||
|
async find<M extends Model>(type: string, params: ApiQueryParamsSingle): Promise<ApiResponseSingle<M>>;
|
||||||
|
async find<Ms extends Model[]>(type: string, params: ApiQueryParamsPlural): Promise<ApiResponsePlural<Ms[number]>>;
|
||||||
|
async find<M extends Model>(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
params?: ApiQueryParamsSingle,
|
||||||
|
options?: ApiQueryRequestOptions<ApiPayloadSingle>
|
||||||
|
): Promise<ApiResponseSingle<M>>;
|
||||||
|
async find<Ms extends Model[]>(
|
||||||
|
type: string,
|
||||||
|
ids: string[],
|
||||||
|
params?: ApiQueryParamsPlural,
|
||||||
|
options?: ApiQueryRequestOptions<ApiPayloadPlural>
|
||||||
|
): Promise<ApiResponsePlural<Ms[number]>>;
|
||||||
|
async find<M extends Model | Model[]>(
|
||||||
|
type: string,
|
||||||
|
idOrParams: string | string[] | ApiQueryParams,
|
||||||
|
query: ApiQueryParams = {},
|
||||||
|
options: ApiQueryRequestOptions<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle> = {}
|
||||||
|
): Promise<ApiResponse<FlatArray<M, 1>>> {
|
||||||
|
let params = query;
|
||||||
|
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||||
|
|
||||||
|
if (idOrParams instanceof Array) {
|
||||||
|
url += '?filter[id]=' + idOrParams.join(',');
|
||||||
|
} else if (typeof idOrParams === 'object') {
|
||||||
|
params = idOrParams;
|
||||||
|
} else if (idOrParams) {
|
||||||
|
url += '/' + idOrParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
.request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
|
||||||
|
method: 'GET',
|
||||||
|
url,
|
||||||
|
params,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
if (payloadIsPlural(payload)) {
|
||||||
|
return this.pushPayload<FlatArray<M, 1>[]>(payload);
|
||||||
|
} else {
|
||||||
|
return this.pushPayload<FlatArray<M, 1>>(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a record from the store by ID.
|
||||||
|
*/
|
||||||
|
getById<M extends Model>(type: string, id: string): M | undefined {
|
||||||
|
return this.data?.[type]?.[id] as M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a record from the store by the value of a model attribute.
|
||||||
|
*
|
||||||
|
* @param type The resource type.
|
||||||
|
* @param key The name of the method on the model.
|
||||||
|
* @param value The value of the model attribute.
|
||||||
|
*/
|
||||||
|
getBy<M extends Model, T = unknown>(type: string, key: keyof M, value: T): M | undefined {
|
||||||
|
// @ts-expect-error No way to do this safely, unfortunately.
|
||||||
|
return this.all(type).filter((model) => model[key]() === value)[0] as M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all loaded records of a specific type.
|
||||||
|
*/
|
||||||
|
all<M extends Model>(type: string): M[] {
|
||||||
|
const records = this.data[type];
|
||||||
|
|
||||||
|
return records ? (Object.values(records) as M[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the given model from the store.
|
||||||
|
*/
|
||||||
|
remove(model: Model): void {
|
||||||
|
delete this.data[model.data.type as string][model.id() as string];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new record of the given type.
|
||||||
|
*
|
||||||
|
* @param type The resource type
|
||||||
|
* @param data Any data to initialize the model with
|
||||||
|
*/
|
||||||
|
createRecord<M extends Model>(type: string, data: ModelData = {}): M {
|
||||||
|
data.type = data.type || type;
|
||||||
|
|
||||||
|
// @ts-expect-error this will complain about initializing abstract models,
|
||||||
|
// but we can safely assume that all models registered with the store are
|
||||||
|
// not abstract.
|
||||||
|
return new this.models[type](data, this);
|
||||||
|
}
|
||||||
|
}
|
@@ -24,8 +24,8 @@ export default function avatar(user: User, attrs: ComponentAttrs = {}): Mithril.
|
|||||||
// uploaded image, or the first letter of their username if they haven't
|
// uploaded image, or the first letter of their username if they haven't
|
||||||
// uploaded one.
|
// uploaded one.
|
||||||
if (user) {
|
if (user) {
|
||||||
const username: string = user.displayName() || '?';
|
const username = user.displayName() || '?';
|
||||||
const avatarUrl: string = user.avatarUrl();
|
const avatarUrl = user.avatarUrl();
|
||||||
|
|
||||||
if (hasTitle) attrs.title = attrs.title || username;
|
if (hasTitle) attrs.title = attrs.title || username;
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ import User from '../models/User';
|
|||||||
* The `username` helper displays a user's username in a <span class="username">
|
* The `username` helper displays a user's username in a <span class="username">
|
||||||
* tag. If the user doesn't exist, the username will be displayed as [deleted].
|
* tag. If the user doesn't exist, the username will be displayed as [deleted].
|
||||||
*/
|
*/
|
||||||
export default function username(user: User): Mithril.Vnode {
|
export default function username(user: User | null | undefined | false): Mithril.Vnode {
|
||||||
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
||||||
|
|
||||||
return <span className="username">{name}</span>;
|
return <span className="username">{name}</span>;
|
||||||
|
@@ -1,108 +0,0 @@
|
|||||||
import app from '../../common/app';
|
|
||||||
import Model from '../Model';
|
|
||||||
import computed from '../utils/computed';
|
|
||||||
import ItemList from '../utils/ItemList';
|
|
||||||
import Badge from '../components/Badge';
|
|
||||||
|
|
||||||
export default class Discussion extends Model {}
|
|
||||||
|
|
||||||
Object.assign(Discussion.prototype, {
|
|
||||||
title: Model.attribute('title'),
|
|
||||||
slug: Model.attribute('slug'),
|
|
||||||
|
|
||||||
createdAt: Model.attribute('createdAt', Model.transformDate),
|
|
||||||
user: Model.hasOne('user'),
|
|
||||||
firstPost: Model.hasOne('firstPost'),
|
|
||||||
|
|
||||||
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
|
|
||||||
lastPostedUser: Model.hasOne('lastPostedUser'),
|
|
||||||
lastPost: Model.hasOne('lastPost'),
|
|
||||||
lastPostNumber: Model.attribute('lastPostNumber'),
|
|
||||||
|
|
||||||
commentCount: Model.attribute('commentCount'),
|
|
||||||
replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)),
|
|
||||||
posts: Model.hasMany('posts'),
|
|
||||||
mostRelevantPost: Model.hasOne('mostRelevantPost'),
|
|
||||||
|
|
||||||
lastReadAt: Model.attribute('lastReadAt', Model.transformDate),
|
|
||||||
lastReadPostNumber: Model.attribute('lastReadPostNumber'),
|
|
||||||
isUnread: computed('unreadCount', (unreadCount) => !!unreadCount),
|
|
||||||
isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
|
|
||||||
|
|
||||||
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
|
|
||||||
hiddenUser: Model.hasOne('hiddenUser'),
|
|
||||||
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
|
|
||||||
|
|
||||||
canReply: Model.attribute('canReply'),
|
|
||||||
canRename: Model.attribute('canRename'),
|
|
||||||
canHide: Model.attribute('canHide'),
|
|
||||||
canDelete: Model.attribute('canDelete'),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a post from the discussion's posts relationship.
|
|
||||||
*
|
|
||||||
* @param {Integer} id The ID of the post to remove.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
removePost(id) {
|
|
||||||
const relationships = this.data.relationships;
|
|
||||||
const posts = relationships && relationships.posts;
|
|
||||||
|
|
||||||
if (posts) {
|
|
||||||
posts.data.some((data, i) => {
|
|
||||||
if (id === data.id) {
|
|
||||||
posts.data.splice(i, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the estimated number of unread posts in this discussion for the current
|
|
||||||
* user.
|
|
||||||
*
|
|
||||||
* @return {Integer}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
unreadCount() {
|
|
||||||
const user = app.session.user;
|
|
||||||
|
|
||||||
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
|
||||||
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
|
||||||
// If posts have been deleted, it's possible that the unread count could exceed the
|
|
||||||
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
|
|
||||||
return Math.min(unreadCount, this.commentCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Badge components that apply to this discussion.
|
|
||||||
*
|
|
||||||
* @return {ItemList}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
badges() {
|
|
||||||
const items = new ItemList();
|
|
||||||
|
|
||||||
if (this.isHidden()) {
|
|
||||||
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a list of all of the post IDs in this discussion.
|
|
||||||
*
|
|
||||||
* @return {Array}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
postIds() {
|
|
||||||
const posts = this.data.relationships.posts;
|
|
||||||
|
|
||||||
return posts ? posts.data.map((link) => link.id) : [];
|
|
||||||
},
|
|
||||||
});
|
|
146
framework/core/js/src/common/models/Discussion.tsx
Normal file
146
framework/core/js/src/common/models/Discussion.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import app from '../../common/app';
|
||||||
|
import Model from '../Model';
|
||||||
|
import computed from '../utils/computed';
|
||||||
|
import ItemList from '../utils/ItemList';
|
||||||
|
import Badge from '../components/Badge';
|
||||||
|
import Mithril from 'mithril';
|
||||||
|
import Post from './Post';
|
||||||
|
import User from './User';
|
||||||
|
|
||||||
|
export default class Discussion extends Model {
|
||||||
|
title() {
|
||||||
|
return Model.attribute<string>('title').call(this);
|
||||||
|
}
|
||||||
|
slug() {
|
||||||
|
return Model.attribute<string>('slug').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt() {
|
||||||
|
return Model.attribute<Date | undefined, string | undefined>('createdAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
user() {
|
||||||
|
return Model.hasOne<User | null>('user').call(this);
|
||||||
|
}
|
||||||
|
firstPost() {
|
||||||
|
return Model.hasOne<Post | null>('firstPost').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPostedAt() {
|
||||||
|
return Model.attribute('lastPostedAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
lastPostedUser() {
|
||||||
|
return Model.hasOne<User | null>('lastPostedUser').call(this);
|
||||||
|
}
|
||||||
|
lastPost() {
|
||||||
|
return Model.hasOne<Post | null>('lastPost').call(this);
|
||||||
|
}
|
||||||
|
lastPostNumber() {
|
||||||
|
return Model.attribute<number | null | undefined>('lastPostNumber').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
commentCount() {
|
||||||
|
return Model.attribute<number | undefined>('commentCount').call(this);
|
||||||
|
}
|
||||||
|
replyCount() {
|
||||||
|
return computed<Number, this>('commentCount', (commentCount) => Math.max(0, ((commentCount as number) ?? 0) - 1)).call(this);
|
||||||
|
}
|
||||||
|
posts() {
|
||||||
|
return Model.hasMany<Post>('posts').call(this);
|
||||||
|
}
|
||||||
|
mostRelevantPost() {
|
||||||
|
return Model.hasOne<Post | null>('mostRelevantPost').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReadAt() {
|
||||||
|
return Model.attribute('lastReadAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
lastReadPostNumber() {
|
||||||
|
return Model.attribute<number | null | undefined>('lastReadPostNumber').call(this);
|
||||||
|
}
|
||||||
|
isUnread() {
|
||||||
|
return computed<boolean, this>('unreadCount', (unreadCount) => !!unreadCount).call(this);
|
||||||
|
}
|
||||||
|
isRead() {
|
||||||
|
return computed<boolean, this>('unreadCount', (unreadCount) => !!(app.session.user && !unreadCount)).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenAt() {
|
||||||
|
return Model.attribute('hiddenAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
hiddenUser() {
|
||||||
|
return Model.hasOne<User | null>('hiddenUser').call(this);
|
||||||
|
}
|
||||||
|
isHidden() {
|
||||||
|
return computed<boolean, this>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
canReply() {
|
||||||
|
return Model.attribute<boolean | undefined>('canReply').call(this);
|
||||||
|
}
|
||||||
|
canRename() {
|
||||||
|
return Model.attribute<boolean | undefined>('canRename').call(this);
|
||||||
|
}
|
||||||
|
canHide() {
|
||||||
|
return Model.attribute<boolean | undefined>('canHide').call(this);
|
||||||
|
}
|
||||||
|
canDelete() {
|
||||||
|
return Model.attribute<boolean | undefined>('canDelete').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a post from the discussion's posts relationship.
|
||||||
|
*/
|
||||||
|
removePost(id: string): void {
|
||||||
|
const posts = this.rawRelationship<Post[]>('posts');
|
||||||
|
|
||||||
|
if (!posts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posts.some((data, i) => {
|
||||||
|
if (id === data.id) {
|
||||||
|
posts.splice(i, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the estimated number of unread posts in this discussion for the current
|
||||||
|
* user.
|
||||||
|
*/
|
||||||
|
unreadCount(): number {
|
||||||
|
const user = app.session.user;
|
||||||
|
|
||||||
|
if (user && (user.markedAllAsReadAt()?.getTime() ?? 0) < this.lastPostedAt()?.getTime()!) {
|
||||||
|
const unreadCount = Math.max(0, (this.lastPostNumber() ?? 0) - (this.lastReadPostNumber() || 0));
|
||||||
|
// If posts have been deleted, it's possible that the unread count could exceed the
|
||||||
|
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
|
||||||
|
return Math.min(unreadCount, this.commentCount() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Badge components that apply to this discussion.
|
||||||
|
*/
|
||||||
|
badges(): ItemList<Mithril.Children> {
|
||||||
|
const items = new ItemList<Mithril.Children>();
|
||||||
|
|
||||||
|
if (this.isHidden()) {
|
||||||
|
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of all of the post IDs in this discussion.
|
||||||
|
*/
|
||||||
|
postIds(): string[] {
|
||||||
|
return this.rawRelationship<Post[]>('posts')?.map((link) => link.id) ?? [];
|
||||||
|
}
|
||||||
|
}
|
@@ -1,17 +0,0 @@
|
|||||||
import Model from '../Model';
|
|
||||||
|
|
||||||
class Group extends Model {}
|
|
||||||
|
|
||||||
Object.assign(Group.prototype, {
|
|
||||||
nameSingular: Model.attribute('nameSingular'),
|
|
||||||
namePlural: Model.attribute('namePlural'),
|
|
||||||
color: Model.attribute('color'),
|
|
||||||
icon: Model.attribute('icon'),
|
|
||||||
isHidden: Model.attribute('isHidden'),
|
|
||||||
});
|
|
||||||
|
|
||||||
Group.ADMINISTRATOR_ID = '1';
|
|
||||||
Group.GUEST_ID = '2';
|
|
||||||
Group.MEMBER_ID = '3';
|
|
||||||
|
|
||||||
export default Group;
|
|
25
framework/core/js/src/common/models/Group.ts
Normal file
25
framework/core/js/src/common/models/Group.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Model from '../Model';
|
||||||
|
|
||||||
|
export default class Group extends Model {
|
||||||
|
static ADMINISTRATOR_ID = '1';
|
||||||
|
static GUEST_ID = '2';
|
||||||
|
static MEMBER_ID = '3';
|
||||||
|
|
||||||
|
nameSingular() {
|
||||||
|
return Model.attribute<string>('nameSingular').call(this);
|
||||||
|
}
|
||||||
|
namePlural() {
|
||||||
|
return Model.attribute<string>('namePlural').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return Model.attribute<string | null>('color').call(this);
|
||||||
|
}
|
||||||
|
icon() {
|
||||||
|
return Model.attribute<string | null>('icon').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
isHidden() {
|
||||||
|
return Model.attribute<boolean>('isHidden').call(this);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,15 +0,0 @@
|
|||||||
import Model from '../Model';
|
|
||||||
|
|
||||||
export default class Notification extends Model {}
|
|
||||||
|
|
||||||
Object.assign(Notification.prototype, {
|
|
||||||
contentType: Model.attribute('contentType'),
|
|
||||||
content: Model.attribute('content'),
|
|
||||||
createdAt: Model.attribute('createdAt', Model.transformDate),
|
|
||||||
|
|
||||||
isRead: Model.attribute('isRead'),
|
|
||||||
|
|
||||||
user: Model.hasOne('user'),
|
|
||||||
fromUser: Model.hasOne('fromUser'),
|
|
||||||
subject: Model.hasOne('subject'),
|
|
||||||
});
|
|
28
framework/core/js/src/common/models/Notification.ts
Normal file
28
framework/core/js/src/common/models/Notification.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Model from '../Model';
|
||||||
|
import User from './User';
|
||||||
|
|
||||||
|
export default class Notification extends Model {
|
||||||
|
contentType() {
|
||||||
|
return Model.attribute<string>('contentType').call(this);
|
||||||
|
}
|
||||||
|
content() {
|
||||||
|
return Model.attribute<string>('content').call(this);
|
||||||
|
}
|
||||||
|
createdAt() {
|
||||||
|
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRead() {
|
||||||
|
return Model.attribute<boolean>('isRead').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
user() {
|
||||||
|
return Model.hasOne<User>('user').call(this);
|
||||||
|
}
|
||||||
|
fromUser() {
|
||||||
|
return Model.hasOne<User | null>('fromUser').call(this);
|
||||||
|
}
|
||||||
|
subject() {
|
||||||
|
return Model.hasOne<Model | null>('subject').call(this);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,31 +0,0 @@
|
|||||||
import Model from '../Model';
|
|
||||||
import computed from '../utils/computed';
|
|
||||||
import { getPlainContent } from '../utils/string';
|
|
||||||
|
|
||||||
export default class Post extends Model {}
|
|
||||||
|
|
||||||
Object.assign(Post.prototype, {
|
|
||||||
number: Model.attribute('number'),
|
|
||||||
discussion: Model.hasOne('discussion'),
|
|
||||||
|
|
||||||
createdAt: Model.attribute('createdAt', Model.transformDate),
|
|
||||||
user: Model.hasOne('user'),
|
|
||||||
|
|
||||||
contentType: Model.attribute('contentType'),
|
|
||||||
content: Model.attribute('content'),
|
|
||||||
contentHtml: Model.attribute('contentHtml'),
|
|
||||||
renderFailed: Model.attribute('renderFailed'),
|
|
||||||
contentPlain: computed('contentHtml', getPlainContent),
|
|
||||||
|
|
||||||
editedAt: Model.attribute('editedAt', Model.transformDate),
|
|
||||||
editedUser: Model.hasOne('editedUser'),
|
|
||||||
isEdited: computed('editedAt', (editedAt) => !!editedAt),
|
|
||||||
|
|
||||||
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
|
|
||||||
hiddenUser: Model.hasOne('hiddenUser'),
|
|
||||||
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
|
|
||||||
|
|
||||||
canEdit: Model.attribute('canEdit'),
|
|
||||||
canHide: Model.attribute('canHide'),
|
|
||||||
canDelete: Model.attribute('canDelete'),
|
|
||||||
});
|
|
73
framework/core/js/src/common/models/Post.ts
Normal file
73
framework/core/js/src/common/models/Post.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Model from '../Model';
|
||||||
|
import computed from '../utils/computed';
|
||||||
|
import { getPlainContent } from '../utils/string';
|
||||||
|
import Discussion from './Discussion';
|
||||||
|
import User from './User';
|
||||||
|
|
||||||
|
export default class Post extends Model {
|
||||||
|
number() {
|
||||||
|
return Model.attribute<number>('number').call(this);
|
||||||
|
}
|
||||||
|
discussion() {
|
||||||
|
return Model.hasOne<Discussion>('discussion').call(this) as Discussion;
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt() {
|
||||||
|
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
user() {
|
||||||
|
return Model.hasOne<User>('user').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType() {
|
||||||
|
return Model.attribute<string | null>('contentType').call(this);
|
||||||
|
}
|
||||||
|
content() {
|
||||||
|
return Model.attribute<string | null | undefined>('content').call(this);
|
||||||
|
}
|
||||||
|
contentHtml() {
|
||||||
|
return Model.attribute<string | null | undefined>('contentHtml').call(this);
|
||||||
|
}
|
||||||
|
renderFailed() {
|
||||||
|
return Model.attribute<boolean | undefined>('renderFailed').call(this);
|
||||||
|
}
|
||||||
|
contentPlain() {
|
||||||
|
return computed<string | null | undefined>('contentHtml', (content) => {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return getPlainContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content as null | undefined;
|
||||||
|
}).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
editedAt() {
|
||||||
|
return Model.attribute('editedAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
editedUser() {
|
||||||
|
return Model.hasOne<User | null>('editedUser').call(this);
|
||||||
|
}
|
||||||
|
isEdited() {
|
||||||
|
return computed<boolean>('editedAt', (editedAt) => !!editedAt).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenAt() {
|
||||||
|
return Model.attribute('hiddenAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
hiddenUser() {
|
||||||
|
return Model.hasOne<User | null>('hiddenUser').call(this);
|
||||||
|
}
|
||||||
|
isHidden() {
|
||||||
|
return computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
canEdit() {
|
||||||
|
return Model.attribute<boolean | undefined>('canEdit').call(this);
|
||||||
|
}
|
||||||
|
canHide() {
|
||||||
|
return Model.attribute<boolean | undefined>('canHide').call(this);
|
||||||
|
}
|
||||||
|
canDelete() {
|
||||||
|
return Model.attribute<boolean | undefined>('canDelete').call(this);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,124 +0,0 @@
|
|||||||
/*global ColorThief*/
|
|
||||||
|
|
||||||
import Model from '../Model';
|
|
||||||
import stringToColor from '../utils/stringToColor';
|
|
||||||
import ItemList from '../utils/ItemList';
|
|
||||||
import computed from '../utils/computed';
|
|
||||||
import GroupBadge from '../components/GroupBadge';
|
|
||||||
|
|
||||||
export default class User extends Model {}
|
|
||||||
|
|
||||||
Object.assign(User.prototype, {
|
|
||||||
username: Model.attribute('username'),
|
|
||||||
slug: Model.attribute('slug'),
|
|
||||||
displayName: Model.attribute('displayName'),
|
|
||||||
email: Model.attribute('email'),
|
|
||||||
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
|
||||||
password: Model.attribute('password'),
|
|
||||||
|
|
||||||
avatarUrl: Model.attribute('avatarUrl'),
|
|
||||||
preferences: Model.attribute('preferences'),
|
|
||||||
groups: Model.hasMany('groups'),
|
|
||||||
|
|
||||||
joinTime: Model.attribute('joinTime', Model.transformDate),
|
|
||||||
lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate),
|
|
||||||
markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
|
|
||||||
unreadNotificationCount: Model.attribute('unreadNotificationCount'),
|
|
||||||
newNotificationCount: Model.attribute('newNotificationCount'),
|
|
||||||
|
|
||||||
discussionCount: Model.attribute('discussionCount'),
|
|
||||||
commentCount: Model.attribute('commentCount'),
|
|
||||||
|
|
||||||
canEdit: Model.attribute('canEdit'),
|
|
||||||
canEditCredentials: Model.attribute('canEditCredentials'),
|
|
||||||
canEditGroups: Model.attribute('canEditGroups'),
|
|
||||||
canDelete: Model.attribute('canDelete'),
|
|
||||||
|
|
||||||
avatarColor: null,
|
|
||||||
color: computed('displayName', 'avatarUrl', 'avatarColor', function (displayName, avatarUrl, avatarColor) {
|
|
||||||
// If we've already calculated and cached the dominant color of the user's
|
|
||||||
// avatar, then we can return that in RGB format. If we haven't, we'll want
|
|
||||||
// to calculate it. Unless the user doesn't have an avatar, in which case
|
|
||||||
// we generate a color from their display name.
|
|
||||||
if (avatarColor) {
|
|
||||||
return 'rgb(' + avatarColor.join(', ') + ')';
|
|
||||||
} else if (avatarUrl) {
|
|
||||||
this.calculateAvatarColor();
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '#' + stringToColor(displayName);
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether or not the user has been seen in the last 5 minutes.
|
|
||||||
*
|
|
||||||
* @return {Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
isOnline() {
|
|
||||||
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Badge components that apply to this user.
|
|
||||||
*
|
|
||||||
* @return {ItemList}
|
|
||||||
*/
|
|
||||||
badges() {
|
|
||||||
const items = new ItemList();
|
|
||||||
const groups = this.groups();
|
|
||||||
|
|
||||||
if (groups) {
|
|
||||||
groups.forEach((group) => {
|
|
||||||
items.add('group' + group.id(), GroupBadge.component({ group }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the dominant color of the user's avatar. The dominant color will
|
|
||||||
* be set to the `avatarColor` property once it has been calculated.
|
|
||||||
*
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
calculateAvatarColor() {
|
|
||||||
const image = new Image();
|
|
||||||
const user = this;
|
|
||||||
|
|
||||||
image.onload = function () {
|
|
||||||
try {
|
|
||||||
const colorThief = new ColorThief();
|
|
||||||
user.avatarColor = colorThief.getColor(this);
|
|
||||||
} catch (e) {
|
|
||||||
// Completely white avatars throw errors due to a glitch in color thief
|
|
||||||
// See https://github.com/lokesh/color-thief/issues/40
|
|
||||||
if (e instanceof TypeError) {
|
|
||||||
user.avatarColor = [255, 255, 255];
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user.freshness = new Date();
|
|
||||||
m.redraw();
|
|
||||||
};
|
|
||||||
image.crossOrigin = 'anonymous';
|
|
||||||
image.src = this.avatarUrl();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the user's preferences.
|
|
||||||
*
|
|
||||||
* @param {Object} newPreferences
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
savePreferences(newPreferences) {
|
|
||||||
const preferences = this.preferences();
|
|
||||||
|
|
||||||
Object.assign(preferences, newPreferences);
|
|
||||||
|
|
||||||
return this.save({ preferences });
|
|
||||||
},
|
|
||||||
});
|
|
164
framework/core/js/src/common/models/User.tsx
Normal file
164
framework/core/js/src/common/models/User.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import ColorThief, { Color } from 'color-thief-browser';
|
||||||
|
|
||||||
|
import Model from '../Model';
|
||||||
|
import stringToColor from '../utils/stringToColor';
|
||||||
|
import ItemList from '../utils/ItemList';
|
||||||
|
import computed from '../utils/computed';
|
||||||
|
import GroupBadge from '../components/GroupBadge';
|
||||||
|
import Mithril from 'mithril';
|
||||||
|
import Group from './Group';
|
||||||
|
|
||||||
|
export default class User extends Model {
|
||||||
|
username() {
|
||||||
|
return Model.attribute<string>('username').call(this);
|
||||||
|
}
|
||||||
|
slug() {
|
||||||
|
return Model.attribute<string>('slug').call(this);
|
||||||
|
}
|
||||||
|
displayName() {
|
||||||
|
return Model.attribute<string>('displayName').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
email() {
|
||||||
|
return Model.attribute<string | undefined>('email').call(this);
|
||||||
|
}
|
||||||
|
isEmailConfirmed() {
|
||||||
|
return Model.attribute<boolean | undefined>('isEmailConfirmed').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
password() {
|
||||||
|
return Model.attribute<string | undefined>('password').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarUrl() {
|
||||||
|
return Model.attribute<string | null>('avatarUrl').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences() {
|
||||||
|
return Model.attribute<Record<string, any> | null | undefined>('preferences').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
groups() {
|
||||||
|
return Model.hasMany<Group>('groups').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinTime() {
|
||||||
|
return Model.attribute('joinTime', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeenAt() {
|
||||||
|
return Model.attribute('lastSeenAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
markedAllAsReadAt() {
|
||||||
|
return Model.attribute('markedAllAsReadAt', Model.transformDate).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadNotificationCount() {
|
||||||
|
return Model.attribute<number | undefined>('unreadNotificationCount').call(this);
|
||||||
|
}
|
||||||
|
newNotificationCount() {
|
||||||
|
return Model.attribute<number | undefined>('newNotificationCount').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
discussionCount() {
|
||||||
|
return Model.attribute<number | undefined>('discussionCount').call(this);
|
||||||
|
}
|
||||||
|
commentCount() {
|
||||||
|
return Model.attribute<number | undefined>('commentCount').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
canEdit() {
|
||||||
|
return Model.attribute<boolean | undefined>('canEdit').call(this);
|
||||||
|
}
|
||||||
|
canEditCredentials() {
|
||||||
|
return Model.attribute<boolean | undefined>('canEditCredentials').call(this);
|
||||||
|
}
|
||||||
|
canEditGroups() {
|
||||||
|
return Model.attribute<boolean | undefined>('canEditGroups').call(this);
|
||||||
|
}
|
||||||
|
canDelete() {
|
||||||
|
return Model.attribute<boolean | undefined>('canDelete').call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return computed<string, User>('displayName', 'avatarUrl', 'avatarColor', (displayName, avatarUrl, avatarColor) => {
|
||||||
|
// If we've already calculated and cached the dominant color of the user's
|
||||||
|
// avatar, then we can return that in RGB format. If we haven't, we'll want
|
||||||
|
// to calculate it. Unless the user doesn't have an avatar, in which case
|
||||||
|
// we generate a color from their display name.
|
||||||
|
if (avatarColor) {
|
||||||
|
return `rgb(${(avatarColor as Color).join(', ')})`;
|
||||||
|
} else if (avatarUrl) {
|
||||||
|
this.calculateAvatarColor();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#' + stringToColor(displayName as string);
|
||||||
|
}).call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected avatarColor: Color | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether or not the user has been seen in the last 5 minutes.
|
||||||
|
*/
|
||||||
|
isOnline(): boolean {
|
||||||
|
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Badge components that apply to this user.
|
||||||
|
*/
|
||||||
|
badges(): ItemList<Mithril.Children> {
|
||||||
|
const items = new ItemList<Mithril.Children>();
|
||||||
|
const groups = this.groups();
|
||||||
|
|
||||||
|
if (groups) {
|
||||||
|
groups.forEach((group) => {
|
||||||
|
items.add(`group${group?.id()}`, <GroupBadge group={group} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the dominant color of the user's avatar. The dominant color will
|
||||||
|
* be set to the `avatarColor` property once it has been calculated.
|
||||||
|
*/
|
||||||
|
protected calculateAvatarColor() {
|
||||||
|
const image = new Image();
|
||||||
|
const user = this;
|
||||||
|
|
||||||
|
image.addEventListener('load', function (this: HTMLImageElement) {
|
||||||
|
try {
|
||||||
|
const colorThief = new ColorThief();
|
||||||
|
user.avatarColor = colorThief.getColor(this);
|
||||||
|
} catch (e) {
|
||||||
|
// Completely white avatars throw errors due to a glitch in color thief
|
||||||
|
// See https://github.com/lokesh/color-thief/issues/40
|
||||||
|
if (e instanceof TypeError) {
|
||||||
|
user.avatarColor = [255, 255, 255];
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.freshness = new Date();
|
||||||
|
m.redraw();
|
||||||
|
});
|
||||||
|
image.crossOrigin = 'anonymous';
|
||||||
|
image.src = this.avatarUrl() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's preferences.
|
||||||
|
*/
|
||||||
|
savePreferences(newPreferences: Record<string, unknown>): Promise<this> {
|
||||||
|
const preferences = this.preferences();
|
||||||
|
|
||||||
|
Object.assign(preferences, newPreferences);
|
||||||
|
|
||||||
|
return this.save({ preferences });
|
||||||
|
}
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
import app from '../../common/app';
|
import app from '../../common/app';
|
||||||
import Model from '../Model';
|
import Model from '../Model';
|
||||||
|
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||||
|
|
||||||
export interface Page<TModel> {
|
export interface Page<TModel> {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -19,6 +20,10 @@ export interface PaginatedListParams {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedListRequestParams extends Omit<ApiQueryParamsPlural, 'include'> {
|
||||||
|
include?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
|
export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
|
||||||
protected location!: PaginationLocation;
|
protected location!: PaginationLocation;
|
||||||
protected pageSize: number;
|
protected pageSize: number;
|
||||||
@@ -39,7 +44,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
|
|
||||||
abstract get type(): string;
|
abstract get type(): string;
|
||||||
|
|
||||||
public clear() {
|
public clear(): void {
|
||||||
this.pages = [];
|
this.pages = [];
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@@ -69,15 +74,15 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
.finally(() => (this.loadingNext = false));
|
.finally(() => (this.loadingNext = false));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseResults(pg: number, results: T[]) {
|
protected parseResults(pg: number, results: ApiResponsePlural<T>): void {
|
||||||
const pageNum = Number(pg);
|
const pageNum = Number(pg);
|
||||||
|
|
||||||
const links = results.payload?.links || {};
|
const links = results.payload?.links;
|
||||||
const page = {
|
const page = {
|
||||||
number: pageNum,
|
number: pageNum,
|
||||||
items: results,
|
items: results,
|
||||||
hasNext: !!links.next,
|
hasNext: !!links?.next,
|
||||||
hasPrev: !!links.prev,
|
hasPrev: !!links?.prev,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) {
|
if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) {
|
||||||
@@ -94,18 +99,21 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
/**
|
/**
|
||||||
* Load a new page of results.
|
* Load a new page of results.
|
||||||
*/
|
*/
|
||||||
protected loadPage(page = 1): Promise<T[]> {
|
protected loadPage(page = 1): Promise<ApiResponsePlural<T>> {
|
||||||
const params = this.requestParams();
|
const reqParams = this.requestParams();
|
||||||
params.page = {
|
|
||||||
...params.page,
|
const include = Array.isArray(reqParams.include) ? reqParams.include.join(',') : reqParams.include;
|
||||||
offset: this.pageSize * (page - 1),
|
|
||||||
|
const params: ApiQueryParamsPlural = {
|
||||||
|
...reqParams,
|
||||||
|
page: {
|
||||||
|
...reqParams.page,
|
||||||
|
offset: this.pageSize * (page - 1),
|
||||||
|
},
|
||||||
|
include,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(params.include)) {
|
return app.store.find<T[]>(this.type, params);
|
||||||
params.include = params.include.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.store.find(this.type, params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +123,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
* @abstract
|
* @abstract
|
||||||
* @see loadPage
|
* @see loadPage
|
||||||
*/
|
*/
|
||||||
protected requestParams(): any {
|
protected requestParams(): PaginatedListRequestParams {
|
||||||
return this.params;
|
return this.params;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +145,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
public refresh(page: number = 1) {
|
public refresh(page: number = 1): Promise<void> {
|
||||||
this.initialLoading = true;
|
this.initialLoading = true;
|
||||||
this.loadingPrev = false;
|
this.loadingPrev = false;
|
||||||
this.loadingNext = false;
|
this.loadingNext = false;
|
||||||
@@ -147,14 +155,14 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
this.location = { page };
|
this.location = { page };
|
||||||
|
|
||||||
return this.loadPage()
|
return this.loadPage()
|
||||||
.then((results: T[]) => {
|
.then((results) => {
|
||||||
this.pages = [];
|
this.pages = [];
|
||||||
this.parseResults(this.location.page, results);
|
this.parseResults(this.location.page, results);
|
||||||
})
|
})
|
||||||
.finally(() => (this.initialLoading = false));
|
.finally(() => (this.initialLoading = false));
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPages() {
|
public getPages(): Page<T>[] {
|
||||||
return this.pages;
|
return this.pages;
|
||||||
}
|
}
|
||||||
public getLocation(): PaginationLocation {
|
public getLocation(): PaginationLocation {
|
||||||
@@ -203,7 +211,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||||||
/**
|
/**
|
||||||
* Stored state parameters.
|
* Stored state parameters.
|
||||||
*/
|
*/
|
||||||
public getParams(): any {
|
public getParams(): P {
|
||||||
return this.params;
|
return this.params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import Model from '../Model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `computed` utility creates a function that will cache its output until
|
* The `computed` utility creates a function that will cache its output until
|
||||||
* any of the dependent values are dirty.
|
* any of the dependent values are dirty.
|
||||||
@@ -7,20 +9,21 @@
|
|||||||
* dependent values.
|
* dependent values.
|
||||||
* @return {Function}
|
* @return {Function}
|
||||||
*/
|
*/
|
||||||
export default function computed(...dependentKeys) {
|
export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T {
|
||||||
const keys = dependentKeys.slice(0, -1);
|
const keys = args.slice(0, -1) as string[];
|
||||||
const compute = dependentKeys.slice(-1)[0];
|
const compute = args.slice(-1)[0] as (this: M, ...args: unknown[]) => T;
|
||||||
|
|
||||||
const dependentValues = {};
|
const dependentValues: Record<string, unknown> = {};
|
||||||
let computedValue;
|
let computedValue: T;
|
||||||
|
|
||||||
return function () {
|
return function (this: M) {
|
||||||
let recompute = false;
|
let recompute = false;
|
||||||
|
|
||||||
// Read all of the dependent values. If any of them have changed since last
|
// Read all of the dependent values. If any of them have changed since last
|
||||||
// time, then we'll want to recompute our output.
|
// time, then we'll want to recompute our output.
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
const value = typeof this[key] === 'function' ? this[key]() : this[key];
|
const attr = (this as Record<string, unknown | (() => unknown)>)[key];
|
||||||
|
const value = typeof attr === 'function' ? attr.call(this) : attr;
|
||||||
|
|
||||||
if (dependentValues[key] !== value) {
|
if (dependentValues[key] !== value) {
|
||||||
recompute = true;
|
recompute = true;
|
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
|||||||
import PostStreamState from '../states/PostStreamState';
|
import PostStreamState from '../states/PostStreamState';
|
||||||
import Discussion from '../../common/models/Discussion';
|
import Discussion from '../../common/models/Discussion';
|
||||||
import Post from '../../common/models/Post';
|
import Post from '../../common/models/Post';
|
||||||
|
import { ApiResponseSingle } from '../../common/Store';
|
||||||
|
|
||||||
export interface IDiscussionPageAttrs extends IPageAttrs {
|
export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -163,7 +164,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
* Load the discussion from the API or use the preloaded one.
|
* Load the discussion from the API or use the preloaded one.
|
||||||
*/
|
*/
|
||||||
load() {
|
load() {
|
||||||
const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null;
|
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||||
if (preloadedDiscussion) {
|
if (preloadedDiscussion) {
|
||||||
// We must wrap this in a setTimeout because if we are mounting this
|
// We must wrap this in a setTimeout because if we are mounting this
|
||||||
// component for the first time on page load, then any calls to m.redraw
|
// component for the first time on page load, then any calls to m.redraw
|
||||||
@@ -173,7 +174,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
} else {
|
} else {
|
||||||
const params = this.requestParams();
|
const params = this.requestParams();
|
||||||
|
|
||||||
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@@ -195,7 +196,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
/**
|
/**
|
||||||
* Initialize the component to display the given discussion.
|
* Initialize the component to display the given discussion.
|
||||||
*/
|
*/
|
||||||
show(discussion: Discussion) {
|
show(discussion: ApiResponseSingle<Discussion>) {
|
||||||
app.history.push('discussion', discussion.title());
|
app.history.push('discussion', discussion.title());
|
||||||
app.setTitle(discussion.title());
|
app.setTitle(discussion.title());
|
||||||
app.setTitleCount(0);
|
app.setTitleCount(0);
|
||||||
@@ -207,7 +208,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
// extensions. We need to distinguish the two so we don't end up displaying
|
// extensions. We need to distinguish the two so we don't end up displaying
|
||||||
// the wrong posts. We do so by filtering out the posts that don't have
|
// the wrong posts. We do so by filtering out the posts that don't have
|
||||||
// the 'discussion' relationship linked, then sorting and splicing.
|
// the 'discussion' relationship linked, then sorting and splicing.
|
||||||
let includedPosts = [];
|
let includedPosts: (Post | undefined)[] = [];
|
||||||
if (discussion.payload && discussion.payload.included) {
|
if (discussion.payload && discussion.payload.included) {
|
||||||
const discussionId = discussion.id();
|
const discussionId = discussion.id();
|
||||||
|
|
||||||
@@ -217,10 +218,11 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
record.type === 'posts' &&
|
record.type === 'posts' &&
|
||||||
record.relationships &&
|
record.relationships &&
|
||||||
record.relationships.discussion &&
|
record.relationships.discussion &&
|
||||||
|
!Array.isArray(record.relationships.discussion.data) &&
|
||||||
record.relationships.discussion.data.id === discussionId
|
record.relationships.discussion.data.id === discussionId
|
||||||
)
|
)
|
||||||
.map((record) => app.store.getById('posts', record.id))
|
.map((record) => app.store.getById<Post>('posts', record.id))
|
||||||
.sort((a: Post, b: Post) => a.createdAt() - b.createdAt())
|
.sort((a?: Post, b?: Post) => (a?.createdAt()?.getTime() ?? 0) - (b?.createdAt()?.getTime() ?? 0))
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +230,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||||||
// posts we want to display. Tell the stream to scroll down and highlight
|
// posts we want to display. Tell the stream to scroll down and highlight
|
||||||
// the specific post that was routed to.
|
// the specific post that was routed to.
|
||||||
this.stream = new PostStreamState(discussion, includedPosts);
|
this.stream = new PostStreamState(discussion, includedPosts);
|
||||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
|
this.stream.goToNumber(m.route.param('near') || (includedPosts[0]?.number() ?? 0), true).then(() => {
|
||||||
this.discussion = discussion;
|
this.discussion = discussion;
|
||||||
|
|
||||||
app.current.set('discussion', discussion);
|
app.current.set('discussion', discussion);
|
||||||
|
@@ -24,7 +24,7 @@ export default class DiscussionsSearchSource implements SearchSource {
|
|||||||
include: 'mostRelevantPost',
|
include: 'mostRelevantPost',
|
||||||
};
|
};
|
||||||
|
|
||||||
return app.store.find('discussions', params).then((results) => {
|
return app.store.find<Discussion[]>('discussions', params).then((results) => {
|
||||||
this.results.set(query, results);
|
this.results.set(query, results);
|
||||||
m.redraw();
|
m.redraw();
|
||||||
});
|
});
|
||||||
@@ -38,9 +38,13 @@ export default class DiscussionsSearchSource implements SearchSource {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||||
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
<Link href={app.route.discussion(discussion, (mostRelevantPost && mostRelevantPost.number()) || 0)}>
|
||||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
{mostRelevantPost ? (
|
||||||
|
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}</div>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@@ -163,7 +163,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
|||||||
const maxHeight =
|
const maxHeight =
|
||||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
||||||
|
|
||||||
this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`);
|
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||||
|
@@ -17,7 +17,7 @@ export default class UsersSearchResults implements SearchSource {
|
|||||||
|
|
||||||
async search(query: string): Promise<void> {
|
async search(query: string): Promise<void> {
|
||||||
return app.store
|
return app.store
|
||||||
.find('users', {
|
.find<User[]>('users', {
|
||||||
filter: { q: query },
|
filter: { q: query },
|
||||||
page: { limit: 5 },
|
page: { limit: 5 },
|
||||||
})
|
})
|
||||||
@@ -33,7 +33,7 @@ export default class UsersSearchResults implements SearchSource {
|
|||||||
const results = (this.results.get(query) || [])
|
const results = (this.results.get(query) || [])
|
||||||
.concat(
|
.concat(
|
||||||
app.store
|
app.store
|
||||||
.all('users')
|
.all<User>('users')
|
||||||
.filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query))
|
.filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query))
|
||||||
)
|
)
|
||||||
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
|
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
|
||||||
|
@@ -1,12 +1,7 @@
|
|||||||
import app from '../../forum/app';
|
import app from '../../forum/app';
|
||||||
import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState';
|
import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState';
|
||||||
import Discussion from '../../common/models/Discussion';
|
import Discussion from '../../common/models/Discussion';
|
||||||
|
import { ApiQueryParamsPlural, ApiResponsePlural } from '../../common/Store';
|
||||||
export interface IRequestParams {
|
|
||||||
include: string[];
|
|
||||||
filter: Record<string, string>;
|
|
||||||
sort?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscussionListParams extends PaginatedListParams {
|
export interface DiscussionListParams extends PaginatedListParams {
|
||||||
sort?: string;
|
sort?: string;
|
||||||
@@ -23,14 +18,13 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
|
|||||||
return 'discussions';
|
return 'discussions';
|
||||||
}
|
}
|
||||||
|
|
||||||
requestParams(): IRequestParams {
|
requestParams(): PaginatedListRequestParams {
|
||||||
const params: IRequestParams = {
|
const params = {
|
||||||
include: ['user', 'lastPostedUser'],
|
include: ['user', 'lastPostedUser'],
|
||||||
filter: this.params.filter || {},
|
filter: this.params.filter || {},
|
||||||
|
sort: this.sortMap()[this.params.sort ?? ''],
|
||||||
};
|
};
|
||||||
|
|
||||||
params.sort = this.sortMap()[this.params.sort ?? ''];
|
|
||||||
|
|
||||||
if (this.params.q) {
|
if (this.params.q) {
|
||||||
params.filter.q = this.params.q;
|
params.filter.q = this.params.q;
|
||||||
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
|
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
|
||||||
@@ -39,8 +33,8 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected loadPage(page: number = 1): Promise<Discussion[]> {
|
protected loadPage(page: number = 1): Promise<ApiResponsePlural<Discussion>> {
|
||||||
const preloadedDiscussions = app.preloadedApiDocument() as Discussion[] | null;
|
const preloadedDiscussions = app.preloadedApiDocument<Discussion[]>();
|
||||||
|
|
||||||
if (preloadedDiscussions) {
|
if (preloadedDiscussions) {
|
||||||
this.initialLoading = false;
|
this.initialLoading = false;
|
||||||
|
Reference in New Issue
Block a user