Skip to content

Commit f335d7f

Browse files
committed
feat(notifications): make notifications configurable by workshop
1 parent 1e03cf2 commit f335d7f

9 files changed

Lines changed: 163 additions & 39 deletions

File tree

docs/configuration.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ These options should be set in the root `package.json` of your workshop.
2525
| `testTab.enabled` | `boolean` | Whether to enable the test tab | `true` |
2626
| `scripts.postupdate` | `string` | Script to run after workshop update | Optional |
2727
| `initialRoute` | `string` | Initial route for the app | `"/"` |
28+
| `notifications` | `array` | Custom notifications for this workshop | `[]` |
2829

2930
## Product Configuration
3031

@@ -161,3 +162,57 @@ Here's an example of some configuration in the root `package.json`:
161162
}
162163
}
163164
```
165+
166+
### Workshop Notifications
167+
168+
You can define custom notifications for your workshop using the `notifications`
169+
array in your `epicshop` config. These notifications are always shown to users
170+
of your workshop (unless expired or muted), and are not subject to product
171+
filtering like
172+
[remote notifications](https://gist.github.com/kentcdodds/c3aaa5141f591cdbb0e6bfcacd361f39).
173+
174+
Each notification object can have the following fields:
175+
176+
| Field | Type | Description |
177+
| ----------- | ------ | ----------------------------------------------------------------- |
178+
| `id` | string | Unique identifier for the notification. |
179+
| `title` | string | The notification title. |
180+
| `message` | string | The notification message. |
181+
| `type` | string | One of `info`, `warning`, or `danger`. |
182+
| `link` | string | (Optional) A URL for users to learn more. |
183+
| `expiresAt` | date | (Optional) A date after which the notification will not be shown. |
184+
185+
**Note:**
186+
187+
- Notifications defined in your workshop config are always included for your
188+
users, regardless of the current product host/slug.
189+
- If `expiresAt` is set and is in the past, the notification will not be shown.
190+
- If a user mutes a notification, it will not be shown again for that user.
191+
- These notifications are merged with any remote notifications (such as those
192+
from the Epicshop notification gist).
193+
194+
#### Example
195+
196+
```json
197+
{
198+
"epicshop": {
199+
// ...other config...
200+
"notifications": [
201+
{
202+
"id": "custom-welcome",
203+
"title": "Welcome to the Workshop!",
204+
"message": "We're glad you're here. Check out the resources tab for more info.",
205+
"type": "info"
206+
},
207+
{
208+
"id": "new-feature",
209+
"title": "New Feature",
210+
"message": "We've added a new feature to the workshop. Check it out in the resources tab.",
211+
"link": "https://www.epicweb.dev/new-feature",
212+
"type": "info",
213+
"expiresAt": "2025-07-01"
214+
}
215+
]
216+
}
217+
}
218+
```

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/workshop-app/app/root.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
userHasAccessToWorkshop,
1414
} from '@epic-web/workshop-utils/epic-api.server'
1515
import { checkForUpdatesCached } from '@epic-web/workshop-utils/git.server'
16+
import { getUnmutedNotifications } from '@epic-web/workshop-utils/notifications.server'
1617
import { makeTimings } from '@epic-web/workshop-utils/timing.server'
1718
import {
1819
getSetClientIdCookieHeader,
@@ -43,7 +44,6 @@ import { GeneralErrorBoundary } from './components/error-boundary.tsx'
4344
import { EpicProgress } from './components/progress-bar.tsx'
4445
import { EpicToaster } from './components/toaster.tsx'
4546
import { TooltipProvider } from './components/ui/tooltip.tsx'
46-
import { getUnmutedNotifications } from './routes/admin+/notifications.server.tsx'
4747
import { Notifications } from './routes/admin+/notifications.tsx'
4848
import { UpdateToast } from './routes/admin+/update-repo.tsx'
4949
import { useTheme } from './routes/theme/index.tsx'

packages/workshop-app/app/routes/admin+/notifications.server.tsx

Lines changed: 0 additions & 37 deletions
This file was deleted.

packages/workshop-app/app/routes/admin+/notifications.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { muteNotification } from '@epic-web/workshop-utils/db.server'
2+
import { type getUnmutedNotifications } from '@epic-web/workshop-utils/notifications.server'
23
import { json, type ActionFunctionArgs } from '@remix-run/node'
34
import { useFetcher } from '@remix-run/react'
45
import { useEffect, useRef } from 'react'
56
import { toast } from 'sonner'
6-
import { type getUnmutedNotifications } from './notifications.server'
77

88
export async function action({ request }: ActionFunctionArgs) {
99
const formData = await request.formData()

packages/workshop-utils/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"./git.server": "./src/git.server.ts",
2727
"./iframe-sync": "./src/iframe-sync.ts",
2828
"./playwright.server": "./src/playwright.server.ts",
29+
"./notifications.server": "./src/notifications.server.ts",
2930
"./process-manager.server": "./src/process-manager.server.ts",
3031
"./test": "./src/test.ts",
3132
"./utils.server": "./src/utils.server.ts",
@@ -118,6 +119,12 @@
118119
"default": "./dist/esm/playwright.server.js"
119120
}
120121
},
122+
"./notifications.server": {
123+
"import": {
124+
"types": "./dist/esm/notifications.server.d.ts",
125+
"default": "./dist/esm/notifications.server.js"
126+
}
127+
},
121128
"./process-manager.server": {
122129
"import": {
123130
"types": "./dist/esm/process-manager.server.d.ts",
@@ -181,6 +188,7 @@
181188
"fs-extra": "^11.2.0",
182189
"globby": "^14.0.2",
183190
"ignore": "^5.3.2",
191+
"json5": "^2.2.3",
184192
"lru-cache": "^11.0.1",
185193
"md5-hex": "^5.0.0",
186194
"mdast-util-mdx-jsx": "^3.1.3",

packages/workshop-utils/src/cache.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type ProblemApp,
1414
type SolutionApp,
1515
} from './apps.server.js'
16+
import { type Notification } from './notifications.server.js'
1617
import { cachifiedTimingReporter, type Timings } from './timing.server.js'
1718
import { checkConnectionCached } from './utils.server.js'
1819

@@ -45,6 +46,8 @@ export const checkForUpdatesCache = makeSingletonCache<{
4546
remoteCommit: string
4647
diffLink: string | null
4748
}>('CheckForUpdatesCache')
49+
export const notificationsCache =
50+
makeSingletonCache<Array<Notification>>('NotificationsCache')
4851

4952
const cacheDir = path.join(os.homedir(), '.epicshop', 'cache')
5053

packages/workshop-utils/src/config.server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ const WorkshopConfigSchema = z
7979
})
8080
.optional(),
8181
initialRoute: z.string().optional().default('/'),
82+
notifications: z
83+
.array(
84+
z.object({
85+
id: z.string(),
86+
title: z.string(),
87+
message: z.string(),
88+
link: z.string().optional(),
89+
type: z.enum(['info', 'warning', 'danger']),
90+
expiresAt: z.date().nullable(),
91+
}),
92+
)
93+
.optional()
94+
.default([]),
8295
})
8396
.transform((data) => {
8497
return {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import json5 from 'json5'
2+
import { z } from 'zod'
3+
import { cachified, notificationsCache } from './cache.server.js'
4+
import { getWorkshopConfig } from './config.server.js'
5+
import { getMutedNotifications } from './db.server.js'
6+
7+
const NotificationSchema = z.object({
8+
id: z.string(),
9+
title: z.string(),
10+
message: z.string(),
11+
link: z.string().optional(),
12+
type: z.enum(['info', 'warning', 'danger']),
13+
products: z
14+
.array(
15+
z.object({
16+
host: z.string(),
17+
slug: z.string().optional(),
18+
}),
19+
)
20+
.optional(),
21+
expiresAt: z
22+
.string()
23+
.nullable()
24+
.transform((val) => (val ? new Date(val) : null)),
25+
})
26+
27+
export type Notification = z.infer<typeof NotificationSchema>
28+
29+
async function getRemoteNotifications() {
30+
return cachified({
31+
key: 'notifications',
32+
cache: notificationsCache,
33+
ttl: 1000 * 60 * 60 * 6,
34+
swr: 1000 * 60 * 60 * 24,
35+
offlineFallbackValue: [],
36+
async getFreshValue() {
37+
const URL =
38+
'https://gist.github.com/kentcdodds/c3aaa5141f591cdbb0e6bfcacd361f39'
39+
const filename = 'notifications.json5'
40+
const response = await fetch(`${URL}/raw/${filename}`)
41+
const text = await response.text()
42+
const json = json5.parse(text)
43+
44+
return NotificationSchema.array().parse(json)
45+
},
46+
}).catch(() => [])
47+
}
48+
49+
export async function getUnmutedNotifications() {
50+
if (ENV.EPICSHOP_DEPLOYED) return []
51+
52+
const remoteNotifications = await getRemoteNotifications()
53+
54+
const config = getWorkshopConfig()
55+
56+
const notificationsToShow = remoteNotifications
57+
.filter((n) => {
58+
if (n.expiresAt && n.expiresAt < new Date()) {
59+
return false
60+
}
61+
return true
62+
})
63+
.filter((n) => {
64+
if (!n.products) return true
65+
return n.products.some((p) => {
66+
return (
67+
p.host === config.product.host &&
68+
(p.slug ? p.slug === config.product.slug : true)
69+
)
70+
})
71+
})
72+
.concat(config.notifications)
73+
74+
const muted = await getMutedNotifications()
75+
76+
const visibleNotifications = notificationsToShow.filter(
77+
(n) => !muted.includes(n.id),
78+
)
79+
80+
return visibleNotifications
81+
}

0 commit comments

Comments
 (0)