@reykjavik/webtools 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ import { dumbId, ObjectFromEntries } from '@reykjavik/hanna-utils';
2
+ import * as v from 'valibot';
3
+ const messageSchema = v.union([
4
+ v.string(),
5
+ v.array(v.union([
6
+ // text nodes
7
+ v.string(),
8
+ // link elements
9
+ v.object({
10
+ tag: v.literal('a'),
11
+ text: v.string(),
12
+ href: v.string(),
13
+ target: v.optional(v.string()),
14
+ hrefLang: v.optional(v.string()),
15
+ lang: v.optional(v.string()),
16
+ }),
17
+ // strong/bold elements
18
+ v.object({
19
+ tag: v.literal('strong'),
20
+ text: v.string(),
21
+ }),
22
+ ])),
23
+ ]);
24
+ // ---------------------------------------------------------------------------
25
+ const defaultKey = 'app~alerts';
26
+ const defaultAlertLevels = ['info', 'warning', 'success', 'error'];
27
+ const defaultDurations = {
28
+ BLINK: 2000,
29
+ SHORT: 4000,
30
+ MEDIUM: 8000,
31
+ LONG: 16000,
32
+ XLONG: 32000,
33
+ INDEFINITE: 0,
34
+ };
35
+ const defaultDefaultDuration = 'SHORT';
36
+ const storeKeys = {};
37
+ /**
38
+ * Factory function that creates an alerter store singleton with optional
39
+ * configuration for the genarated alerts.
40
+ *
41
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#createalerterstore
42
+ */
43
+ /*#__NO_SIDE_EFFECTS__*/
44
+ export const createAlerterStore = (cfg = {}) => {
45
+ const STORE_KEY = cfg.key || defaultKey;
46
+ if (storeKeys[STORE_KEY]) {
47
+ throw new Error(`An alerter store with key "${STORE_KEY}" already exists.`);
48
+ }
49
+ storeKeys[STORE_KEY] = true;
50
+ const storgae = cfg.storage || (typeof sessionStorage !== 'undefined' ? sessionStorage : undefined);
51
+ const alertLevels = cfg.levels || defaultAlertLevels;
52
+ const durations = cfg.durations || defaultDurations;
53
+ const DEFAULT_DURATION = cfg.durations
54
+ ? cfg.defaultDuration
55
+ : defaultDefaultDuration;
56
+ const _notificationSchema = v.object({
57
+ level: v.picklist(alertLevels),
58
+ message: messageSchema,
59
+ type: v.optional(cfg.types ? v.picklist(cfg.types) : v.string()),
60
+ flags: v.optional(v.array(cfg.flags ? v.picklist(cfg.flags) : v.string())),
61
+ duration: v.optional(v.number()),
62
+ id: v.string(),
63
+ });
64
+ const alertsSchema = v.object({
65
+ active: v.array(_notificationSchema),
66
+ pending: v.array(v.intersect([_notificationSchema, v.object({ showAt: v.number() })])),
67
+ });
68
+ /** Global array of inflight alert notifications */
69
+ const alerts = {
70
+ active: [],
71
+ pending: [],
72
+ };
73
+ const _saveAlertsToStorage = storgae
74
+ ? () => storgae.setItem(STORE_KEY, JSON.stringify(alerts))
75
+ : () => undefined;
76
+ // ---------------------------------------------------------------------------
77
+ /** Array of callbacks to call whenever alerts are activated or cleared */
78
+ const subscriptions = [];
79
+ let isEmitting;
80
+ /** Calls all subscribed callbacks with the currently active alerts and the event type. */
81
+ const emitEvent = (meta, callback) => {
82
+ if (callback) {
83
+ setTimeout(() => callback(alerts.active, meta));
84
+ return;
85
+ }
86
+ _saveAlertsToStorage();
87
+ clearTimeout(isEmitting);
88
+ // For consistency, always delay pinging the subscribed callbacks until next tick.
89
+ isEmitting = setTimeout(() => {
90
+ subscriptions.forEach((callback) => {
91
+ callback(alerts.active, meta);
92
+ });
93
+ });
94
+ };
95
+ const clearAlert = (id) => {
96
+ let found = false;
97
+ alerts.active.some((alert, idx) => {
98
+ if (alert.id === id) {
99
+ alerts.active = alerts.active.toSpliced(idx, 1);
100
+ found = true;
101
+ return true;
102
+ }
103
+ });
104
+ alerts.pending.some((alert, idx) => {
105
+ if (alert.id === id) {
106
+ alerts.pending = alerts.pending.toSpliced(idx, 1);
107
+ found = true;
108
+ return true;
109
+ }
110
+ });
111
+ if (found) {
112
+ emitEvent({ type: 'clear', ids: [id] });
113
+ }
114
+ };
115
+ // ---------------------------------------------------------------------------
116
+ const _unsubscribe = (callback) => {
117
+ const idx = subscriptions.indexOf(callback);
118
+ if (idx > -1) {
119
+ subscriptions.splice(idx, 1);
120
+ }
121
+ };
122
+ const subscribe = (callback) => {
123
+ if (subscriptions.indexOf(callback) === -1) {
124
+ subscriptions.push(callback);
125
+ // Should we allow opting-out of immediate invocations via function param?
126
+ if (alerts.active.length) {
127
+ emitEvent({
128
+ type: 'add',
129
+ ids: alerts.active.map((t) => t.id),
130
+ }, callback);
131
+ }
132
+ }
133
+ return () => _unsubscribe(callback);
134
+ };
135
+ // ---------------------------------------------------------------------------
136
+ const buildNotification = (_payload, level) => {
137
+ // Strip away duration and delay (not part of the notification object)
138
+ const { duration, delay, ...payload } = _payload;
139
+ const durationMs = durations[(duration || DEFAULT_DURATION || '')] || 0;
140
+ return {
141
+ ...payload,
142
+ level,
143
+ id: dumbId(), // Make unique ID for the notification
144
+ ...(durationMs && { duration: durationMs }),
145
+ ...(delay && delay > 50 && { showAt: Date.now() + delay }),
146
+ };
147
+ };
148
+ // add dismiss and setFlags dispatcher functions
149
+ // this way they stay stable across updates to the same alert, which can be useful for UI components that want to update or dismiss an alert after it's been rendered.
150
+ const addMethodsToAlertInfo = (notification) => {
151
+ const id = notification.id;
152
+ return {
153
+ ...notification,
154
+ dismiss: () => clearAlert(id),
155
+ setFlags: (value) => {
156
+ const notification = alerts.active.find((t) => t.id === id);
157
+ if (!notification) {
158
+ return;
159
+ }
160
+ const oldFlags = notification.flags;
161
+ const flags = typeof value === 'string'
162
+ ? [value]
163
+ : Array.isArray(value)
164
+ ? [...value]
165
+ : value(notification.flags);
166
+ if (flags === oldFlags) {
167
+ return;
168
+ }
169
+ alerts.active = alerts.active.toSpliced(alerts.active.findIndex((t) => t.id === id), 1, { ...notification, flags });
170
+ emitEvent({ type: 'change', ids: [id] });
171
+ },
172
+ };
173
+ };
174
+ const preparePendingAlertActivation = ({ id, showAt }) => {
175
+ setTimeout(() => {
176
+ const idx = alerts.pending.findIndex((t) => t.id === id);
177
+ if (idx === -1) {
178
+ return;
179
+ }
180
+ // Find and remove the pending in alert in one fell swoop
181
+ const { showAt, ...clonedAsActive } = alerts.pending.splice(idx, 1)[0];
182
+ alerts.active = [...alerts.active, addMethodsToAlertInfo(clonedAsActive)];
183
+ emitEvent({ type: 'add', ids: [id] });
184
+ }, showAt - Date.now());
185
+ };
186
+ const _addAlert = (payload, level) => {
187
+ const notification = buildNotification(payload, level);
188
+ if (!notification.showAt) {
189
+ // Notification starts active. Clone the array.
190
+ alerts.active = [...alerts.active, addMethodsToAlertInfo(notification)];
191
+ emitEvent({ type: 'add', ids: [notification.id] });
192
+ return;
193
+ }
194
+ // Set up delayed dispatch of the notification
195
+ // Store it as pending
196
+ alerts.pending.push(notification);
197
+ // Persist the updated alerts state immediately
198
+ _saveAlertsToStorage();
199
+ // Set up a timer for make it active
200
+ preparePendingAlertActivation(notification);
201
+ };
202
+ const alerter = ObjectFromEntries(alertLevels.map((level) => [
203
+ level,
204
+ (payload) => _addAlert(payload, level),
205
+ ]));
206
+ // ---------------------------------------------------------------------------
207
+ // On module load, read saved alerts from the storage (def. `sessionStorage`),
208
+ // if available.
209
+ // This allows us to persist alerts across page reloads,
210
+ // but NOT across tabs or browser sessions.
211
+ storgae &&
212
+ (() => {
213
+ const storedAlerts = storgae.getItem(STORE_KEY);
214
+ if (!storedAlerts) {
215
+ return;
216
+ }
217
+ try {
218
+ // NOTE: This TypeAssertion is wrong rn and only becomes true after
219
+ // `addMethodsToAlertInfo` is called on the active alerts.
220
+ // Also TS has a hard time understanding the dynamically generated
221
+ // schema and that they're actually correct in terms of the configuration
222
+ // type params `Level`, `Type` and `Flag`.
223
+ const paesed = v.parse(alertsSchema, JSON.parse(storedAlerts));
224
+ alerts.pending = paesed.pending;
225
+ alerts.active = paesed.active.map(addMethodsToAlertInfo);
226
+ }
227
+ catch (e) {
228
+ console.error('Failed to parse stored alerts:', e);
229
+ return;
230
+ }
231
+ const now = Date.now();
232
+ const newActive = [];
233
+ // Check pending alerts and set up timers, unless they should already
234
+ // be active, in which case move them to active immediately.
235
+ alerts.pending = alerts.pending.filter((alert) => {
236
+ if (alert.showAt <= now) {
237
+ // Remove pending alerts that should now be active. Store them in `newActive`
238
+ newActive.push(alert);
239
+ return false;
240
+ }
241
+ // Keep the rest, and set up timers to activate them later.
242
+ preparePendingAlertActivation(alert);
243
+ return true;
244
+ });
245
+ // Append `newActive` to the active alerts array
246
+ alerts.active.push(...newActive
247
+ // Make sure alerts with earlier `showAt` are shown first
248
+ .sort((a, b) => a.showAt - b.showAt)
249
+ // remove showAt, not part of active alerts
250
+ .map(({ showAt, ...rest }) => addMethodsToAlertInfo(rest)));
251
+ // Save the (possibly) cleaned up alerts back to the storage, because why not. :-D
252
+ _saveAlertsToStorage();
253
+ })();
254
+ return {
255
+ /**
256
+ * Singleton object with methods for showing alerts of different levels.
257
+ * Pass a payload object to the method of the level you want to dispatch,
258
+ * and the alert will be added to the store.
259
+ *
260
+ * Use `subscribeToAlerts` elsewhere in the app to subscribe to alert
261
+ * notifications and display them.
262
+ *
263
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#createalerterstore
264
+ */
265
+ alerter,
266
+ /**
267
+ * Subscribes to alert events. The provided callback will be called whenever a
268
+ * alert is added or cleared.
269
+ *
270
+ * The callback is called immediately upon subscription if there are already
271
+ * active alerts.
272
+ *
273
+ * Returns an unsubscribe function that can be called to stop receiving alert
274
+ * events.
275
+ *
276
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#createalerterstore
277
+ */
278
+ subscribe,
279
+ };
280
+ };
@@ -0,0 +1,39 @@
1
+ import React, { ReactNode } from 'react';
2
+ import { AlertMessage } from './index.js';
3
+ type SubsScriber<AlertInfo> = (callback: (alerts: Array<AlertInfo>, meta: {
4
+ type: string;
5
+ ids: Array<string>;
6
+ }) => void) => () => void;
7
+ /**
8
+ * Factory function that creates a React subscription hook and a container
9
+ * component linked to a specific alerter store subscibe function.
10
+ *
11
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#makereactsubscription
12
+ */
13
+ export declare const makeReactSubscription: <AlertInfo>(subscribe: SubsScriber<AlertInfo>) => {
14
+ useAlerter: () => AlertInfo[];
15
+ AlertsContainer: (props: {
16
+ children: (alerts: Array<AlertInfo>) => ReactNode;
17
+ }) => React.ReactNode;
18
+ };
19
+ /**
20
+ * Helper to render an alerter alert message, which can be a simple string or a
21
+ * more complex array of strings and objects representing links and rich (bold)
22
+ * text formatting.
23
+ *
24
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#renderalertmessage
25
+ */
26
+ export declare const renderAlertMessage: {
27
+ (message: AlertMessage, linkComponent?: renderAlertMessage.LinkRenderer): ReactNode;
28
+ withLinkRenderer(LinkCompnent: renderAlertMessage.LinkRenderer): (message: AlertMessage) => React.ReactNode;
29
+ };
30
+ export declare namespace renderAlertMessage {
31
+ type LinkRendererProps = Omit<Extract<AlertMessage[number], {
32
+ tag: 'a';
33
+ }>, 'tag' | 'text'> & {
34
+ children: ReactNode;
35
+ };
36
+ export type LinkRenderer = (props: LinkRendererProps) => ReactNode;
37
+ export {};
38
+ }
39
+ export {};
@@ -0,0 +1,58 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ /**
3
+ * Factory function that creates a React subscription hook and a container
4
+ * component linked to a specific alerter store subscibe function.
5
+ *
6
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#makereactsubscription
7
+ */
8
+ /*#__NO_SIDE_EFFECTS__*/
9
+ export const makeReactSubscription = (subscribe) => {
10
+ const useAlerter = () => {
11
+ const [alerts, setAlerts] = useState([]);
12
+ useEffect(() => subscribe((alerts) => setAlerts(alerts)), []);
13
+ return alerts;
14
+ };
15
+ const AlertsContainer = (props) => {
16
+ const alerts = useAlerter();
17
+ return props.children(alerts);
18
+ };
19
+ return {
20
+ useAlerter,
21
+ AlertsContainer,
22
+ };
23
+ };
24
+ /**
25
+ * Helper to render an alerter alert message, which can be a simple string or a
26
+ * more complex array of strings and objects representing links and rich (bold)
27
+ * text formatting.
28
+ *
29
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#renderalertmessage
30
+ */
31
+ /*#__NO_SIDE_EFFECTS__*/
32
+ export const renderAlertMessage = (message, linkComponent) => {
33
+ const Link = linkComponent || 'a';
34
+ return typeof message === 'string'
35
+ ? message
36
+ : message.flatMap((part, i) => {
37
+ if (typeof part === 'string') {
38
+ return ` ${part}`;
39
+ }
40
+ if (part.tag === 'a') {
41
+ const { text, tag, ...linkProps } = part;
42
+ return [
43
+ ' ',
44
+ React.createElement(Link, { key: i, ...linkProps }, text),
45
+ ];
46
+ }
47
+ return [' ', React.createElement(part.tag, { key: i }, part.text)];
48
+ });
49
+ };
50
+ /**
51
+ * Retrns a curried version of `renderAlertMessage` that uses the provided
52
+ * `LinkComponent` for rendering links in alert messages.
53
+ *
54
+ * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#renderalertmessagewithlinkrenderer
55
+ */
56
+ /*#__NO_SIDE_EFFECTS__*/
57
+ renderAlertMessage.withLinkRenderer =
58
+ (LinkCompnent) => (message) => renderAlertMessage(message, LinkCompnent);
package/esm/async.d.ts CHANGED
@@ -102,7 +102,7 @@ export declare const cachifyAsync: <R, F extends (...args: Array<any>) => Promis
102
102
  * Function to optionally set a custom TTL on success and/or error results,
103
103
  * when the promise resolves.
104
104
  *
105
- * If `undefined` is returned, the default `ttlMs` and` `throttleMs` settings
105
+ * If `undefined` is returned, the default `ttl` and` `throttle` settings
106
106
  * are used.
107
107
  */
108
108
  customTtl?: (args: Parameters<F>, result: Result.TupleObj<R>) => TTL | undefined;
package/esm/async.js CHANGED
@@ -190,6 +190,7 @@ const DEFAULT_THROTTLING_MS = '30s';
190
190
  *
191
191
  * @see https://github.com/reykjavikcity/webtools/blob/v0.3/README.md#cachifyasync
192
192
  */
193
+ /*#__NO_SIDE_EFFECTS__*/
193
194
  export const cachifyAsync = (opts) => {
194
195
  const { fn, getKey = (...args) => JSON.stringify(args), customTtl, returnStale } = opts;
195
196
  // Set up the cache object
package/esm/index.d.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  /// <reference path="./fixIcelandicLocale.d.ts" />
6
6
  /// <reference path="./errorhandling.d.ts" />
7
7
  /// <reference path="./react-router/http.d.ts" />
8
+ /// <reference path="./alertsStore/index.d.ts" />
8
9
  /// <reference path="./SiteImprove.d.tsx" />
9
10
  /// <reference path="./CookieHubConsent.d.tsx" />
10
11
  /// <reference path="./react-router/Wait.d.tsx" />
12
+ /// <reference path="./alertsStore/react.d.tsx" />
11
13
  /// <reference path="./next/http.d.tsx" />
12
14
 
13
15
  export {};
package/index.d.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  /// <reference path="./fixIcelandicLocale.d.ts" />
6
6
  /// <reference path="./errorhandling.d.ts" />
7
7
  /// <reference path="./react-router/http.d.ts" />
8
+ /// <reference path="./alertsStore/index.d.ts" />
8
9
  /// <reference path="./SiteImprove.d.tsx" />
9
10
  /// <reference path="./CookieHubConsent.d.tsx" />
10
11
  /// <reference path="./react-router/Wait.d.tsx" />
12
+ /// <reference path="./alertsStore/react.d.tsx" />
11
13
  /// <reference path="./next/http.d.tsx" />
12
14
 
13
15
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reykjavik/webtools",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Misc. JS/TS helpers used by Reykjavík City's web dev teams.",
5
5
  "main": "index.js",
6
6
  "repository": "ssh://git@github.com:reykjavikcity/webtools.git",
@@ -11,7 +11,8 @@
11
11
  ],
12
12
  "license": "MIT",
13
13
  "dependencies": {
14
- "@reykjavik/hanna-utils": "^0.2.20"
14
+ "@reykjavik/hanna-utils": "^0.2.22",
15
+ "valibot": "^1.2.0"
15
16
  },
16
17
  "peerDependencies": {
17
18
  "@vanilla-extract/css": "^1.14.1",
@@ -76,6 +77,10 @@
76
77
  "import": "./esm/react-router/http.js",
77
78
  "require": "./react-router/http.js"
78
79
  },
80
+ "./alertsStore": {
81
+ "import": "./esm/alertsStore/index.js",
82
+ "require": "./alertsStore/index.js"
83
+ },
79
84
  "./SiteImprove": {
80
85
  "import": "./esm/SiteImprove.js",
81
86
  "require": "./SiteImprove.js"
@@ -88,6 +93,10 @@
88
93
  "import": "./esm/react-router/Wait.js",
89
94
  "require": "./react-router/Wait.js"
90
95
  },
96
+ "./alertsStore/react": {
97
+ "import": "./esm/alertsStore/react.js",
98
+ "require": "./alertsStore/react.js"
99
+ },
91
100
  "./next/http": {
92
101
  "import": "./esm/next/http.js",
93
102
  "require": "./next/http.js"