Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export default [
files: [ 'tests/**/*' ],
rules: {
'no-undef': 'off',
'jsdoc/check-tag-names': [
'error',
{ definedTags: [ 'jest-environment' ] },
],
},
},
];
78 changes: 70 additions & 8 deletions src/store/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import apiFetch from '@wordpress/api-fetch';

import { API_PATH } from '../constants';
import type { Notice } from '../types';

import { normalizeNotices } from './normalize';
import type { RestNotification } from './normalize';

/**
* The helpers exposed to a store thunk by `@wordpress/data`.
*/
export type ThunkArgs = {
dispatch: {
fetchUpdates: () => Promise< Notice[] >;
hydrate: ( payload: Notice[] ) => ReturnType< typeof hydrate >;
};
};

/**
* Action creator to register a display context in the store.
*
* @param context The slug of the context to register.
* @return A redux action.
*/
export const registerContext = ( context: string ) => {
return {
type: 'REGISTER_CONTEXT' as const,
context,
};
};

/**
* Action creator to hydrate a notice from the store.
*
Expand Down Expand Up @@ -68,14 +97,47 @@ export const updateNotice = (
};

/**
* Action creator to fetch notices.
* In-flight fetch promise, used to de-duplicate concurrent `fetchUpdates` dispatches.
*
* @param path The REST API route from which to fetch notices.
* @return A redux action.
* The `getNotices` resolver runs once per distinct context, so on a page that renders
* several contexts (adminbar + dashboard) the first reads happen at the same time and
* would otherwise issue one identical request each.
*/
export const fetchAPI = ( path = '' ) => {
return {
type: 'FETCH' as const,
path,
let inFlightFetch: Promise< Notice[] > | null = null;

/**
* Thunk action creator to fetch notifications from the REST API and hydrate them
* into the store.
*
* Unlike the previous generator resolver, this thunk dispatches the `HYDRATE` action
* itself, so the fetched notices actually reach the state.
*
* @return A thunk that fetches and hydrates the notices.
*/
export const fetchUpdates =
() =>
async ( { dispatch }: ThunkArgs ) => {
if ( inFlightFetch ) {
return inFlightFetch;
}

const request = ( async () => {
try {
const response = ( await apiFetch( {
path: API_PATH,
} ) ) as RestNotification[];

const notices = normalizeNotices( response );

dispatch.hydrate( notices );

return notices;
} finally {
inFlightFetch = null;
}
} )();

inFlightFetch = request;

return request;
};
};
24 changes: 0 additions & 24 deletions src/store/controls.ts

This file was deleted.

29 changes: 20 additions & 9 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,37 @@ import { STORE_NAMESPACE } from '../constants';
import type { Notice } from '../types';

import * as actions from './actions';
import * as controls from './controls';
import reducer from './reducer';
import * as resolvers from './resolvers';
import * as selectors from './selectors';

/**
* The notifications redux store type.
*/
export type State = Record< string, Notice[] >;

type ValuesOf<
T extends Record< string, unknown >,
K extends keyof T = keyof T,
> = T[ K ];
/**
* The plain (non-thunk) actions handled by the notifications reducer.
*/
export type Action =
| ReturnType< typeof actions.registerContext >
| ReturnType< typeof actions.hydrate >
| ReturnType< typeof actions.clear >
| ReturnType< typeof actions.addNotice >
| ReturnType< typeof actions.removeNotice >
| ReturnType< typeof actions.updateNotice >;

/**
* The actions of the notifications redux reducer.
* Resolvers fetch the notices the first time a context is read.
*
* Implemented as a thunk so no separate controls/resolvers indirection is needed.
*/
export type Action = ReturnType< ValuesOf< typeof actions > >;
const resolvers = {
getNotices:
() =>
async ( { dispatch }: actions.ThunkArgs ) => {
await dispatch.fetchUpdates();
},
};

/**
* Creating a store for the redux state.
Expand All @@ -35,7 +47,6 @@ const store = createReduxStore< State, typeof actions, typeof selectors >(
reducer,
actions,
selectors,
controls,
resolvers,
}
);
Expand Down
150 changes: 150 additions & 0 deletions src/store/normalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type {
DashiconsIcon,
ImageIcon,
Notice,
NoticeAction,
NoticeIcon,
NoticeStatus,
SvgIcon,
} from '../types';

/**
* The shape of a notification as returned by the `wp-notifications/v1` REST API.
*
* Field names are snake_case, while the client `Notice` type is camelCase, so the
* payload has to be normalised before it enters the store.
*/
export type RestNotification = {
accept_label?: string;
accept_link?: string;
channel_name?: string;
channel_title?: string;
context?: string;
created_at?: string | null;
dismiss_label?: string;
dismissed_at?: string | null;
displayed_at?: string | null;
expires_at?: string | null;
icon?: string | { dashicons?: string; src?: string; svg?: string } | null;
id: number;
is_dismissible?: boolean;
message?: string;
severity?: string | null;
status?: string;
title?: string;
user_id?: number;
};

/**
* Normalise the many possible REST icon shapes to the `NoticeIcon` union.
*
* The REST schema exposes `icon` as a dashicon slug string, but this also accepts an
* already-structured icon object so the mapper keeps working if the payload changes.
*
* @param icon The icon value from the REST payload.
*
* @return The normalised icon, or undefined when no usable icon was provided.
*/
const normalizeIcon = (
icon: RestNotification[ 'icon' ]
): NoticeIcon | undefined => {
if ( typeof icon === 'string' ) {
return { dashicons: icon } as DashiconsIcon;
}

if ( icon && typeof icon === 'object' ) {
if ( typeof icon.dashicons === 'string' ) {
return { dashicons: icon.dashicons } as DashiconsIcon;
}
if ( typeof icon.src === 'string' ) {
return { src: icon.src } as ImageIcon;
}
if ( typeof icon.svg === 'string' ) {
return { svg: icon.svg } as SvgIcon;
}
}

return undefined;
};

/**
* Normalise the flat REST action fields into a `NoticeAction`.
*
* @param raw The raw REST notification.
*
* @return The normalised action, or undefined when the notification has no action.
*/
const normalizeAction = ( raw: RestNotification ): NoticeAction | undefined => {
if ( ! raw.accept_link && ! raw.accept_label && ! raw.dismiss_label ) {
return undefined;
}

return {
acceptLink: raw.accept_link,
acceptMessage: raw.accept_label,
dismissLabel: raw.dismiss_label,
dismissible: raw.is_dismissible,
};
};

/**
* Normalise a single notification from the REST payload to the store `Notice` shape.
*
* @param raw The raw REST notification.
*
* @return The normalised notice.
*/
export const normalizeNotice = ( raw: RestNotification ): Notice => {
const notice: Notice = {
id: raw.id,
message: raw.message ?? '',
date: raw.created_at ? new Date( raw.created_at ) : new Date(),
};

if ( raw.title !== undefined ) {
notice.title = raw.title;
}
if ( raw.context !== undefined ) {
notice.context = raw.context;
}
if ( raw.status !== undefined ) {
notice.status = raw.status as NoticeStatus;
}
if ( raw.severity !== undefined && raw.severity !== null ) {
notice.severity = raw.severity;
}
if ( raw.channel_title !== undefined ) {
notice.source = raw.channel_title;
}
if ( raw.is_dismissible !== undefined ) {
notice.dismissible = raw.is_dismissible;
}
if ( raw.dismiss_label !== undefined ) {
notice.dismissLabel = raw.dismiss_label;
}

const icon = normalizeIcon( raw.icon );
if ( icon ) {
notice.icon = icon;
}

const action = normalizeAction( raw );
if ( action ) {
notice.action = action;
}

return notice;
};

/**
* Normalise a list of notifications returned by the REST API.
*
* @param raw The raw REST payload.
*
* @return The normalised notices, or an empty array when nothing usable was received.
*/
export const normalizeNotices = (
raw: RestNotification[] | null | undefined
): Notice[] => {
return Array.isArray( raw ) ? raw.map( normalizeNotice ) : [];
};
Loading
Loading