-
Notifications
You must be signed in to change notification settings - Fork 12
Expose reusable settings UI for add-ons #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
223beb9
7188f19
54c512d
02d6c44
4b62286
76c081c
d36b30b
4e2c496
21b1ef9
bde6fe2
6cd7d72
4f8f018
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /** | ||
| * Public component surface for add-ons. | ||
| * | ||
| * Built as the `components` webpack entry (library `textyComponents`, handle | ||
| * `texty-components`) and consumed by add-ons via `import { … } from | ||
| * '@texty/components'`, which webpack externalizes to the shared global. | ||
| */ | ||
| export { default as NotificationGroupSettings } from '../pages/notifications/components/NotificationGroupSettings'; | ||
| export { default as PhoneField } from './PhoneField'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,20 +8,19 @@ import apiFetch from '@wordpress/api-fetch'; | |
| import { useEffect, useState } from '@wordpress/element'; | ||
| import { applyFilters } from '@wordpress/hooks'; | ||
| import { __ } from '@wordpress/i18n'; | ||
| import { Bell, Globe, ShoppingCart, Store } from 'lucide-react'; | ||
| import { addQueryArgs } from '@wordpress/url'; | ||
| import { Bell, Globe, ShoppingCart, Store, Zap } from 'lucide-react'; | ||
| import type { ComponentType } from 'react'; | ||
|
|
||
| import NotificationGroupSkeleton from './NotificationGroupSkeleton'; | ||
|
|
||
| // Separator for child field ids — `<id>_message`, `<id>_recipients`. | ||
| const SEP = '_'; | ||
|
|
||
| // Maps the backend `icon` string on the page element to a lucide component. | ||
| const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = { | ||
| Globe, | ||
| ShoppingCart, | ||
| Store, | ||
| Bell, | ||
| Zap, | ||
| }; | ||
|
|
||
| const assetUrl: string = window.texty?.asset_url ?? ''; | ||
|
|
@@ -38,22 +37,29 @@ type SchemaResponse = { | |
| values: Record<string, unknown>; | ||
| }; | ||
|
|
||
| type SavePayloadEntry = { | ||
| enabled: boolean; | ||
| message: string; | ||
| recipients?: string[]; | ||
| }; | ||
|
|
||
| type SavePayload = Record<string, SavePayloadEntry>; | ||
| type SavePayload = Record<string, Record<string, unknown>>; | ||
|
|
||
| type Props = { | ||
| groupId: string; | ||
| // Built-in notifications group (renders /notifications/schema?group=…). | ||
| groupId?: string; | ||
| // Or point at any endpoint returning { schema, values } (e.g. Texty Pro's | ||
| schemaPath?: string; | ||
| savePath?: string; | ||
| }; | ||
|
|
||
| // The full plugin-ui Settings schema is built on the server | ||
| // (`GET /texty/v1/notifications/schema?group=…`). This component only fetches | ||
| // it, renders it, and maps edits back into the save payload. | ||
| const NotificationGroupSettings = ({ groupId }: Props) => { | ||
| // The full plugin-ui Settings schema is built on the server. This component only | ||
| // fetches it, renders it, and folds each collapsible card's children back into | ||
| // the per-id save payload — generic over any group/feature that follows the | ||
| // `<id>` + `<id>_<key>` / `<id>::<key>` field convention. | ||
| const NotificationGroupSettings = ({ | ||
| groupId, | ||
| schemaPath, | ||
| savePath, | ||
| }: Props) => { | ||
| const fetchPath = | ||
| schemaPath ?? | ||
| addQueryArgs('/texty/v1/notifications/schema', { group: groupId ?? '' }); | ||
| const postPath = savePath ?? '/texty/v1/notifications'; | ||
|
Comment on lines
+54
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Key the form by endpoint, not only by group ID. After this was generalized, every custom consumer that passes Proposed fix- key={groupId}
+ key={fetchPath}Also applies to: 215-216 🤖 Prompt for AI Agents |
||
| const [schema, setSchema] = useState<SettingsElement[] | null>(null); | ||
| // plugin-ui <Settings> is controlled for `values` (external values take | ||
| // precedence over its internal edits), so edits must be lifted into state | ||
|
|
@@ -68,11 +74,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => { | |
| const load = async (): Promise<void> => { | ||
| setLoading(true); | ||
| try { | ||
| const resp = await apiFetch<SchemaResponse>({ | ||
| path: `/texty/v1/notifications/schema?group=${encodeURIComponent( | ||
| groupId | ||
| )}`, | ||
| }); | ||
| const resp = await apiFetch<SchemaResponse>({ path: fetchPath }); | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
|
|
@@ -96,7 +98,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => { | |
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [groupId]); | ||
| }, [fetchPath]); | ||
|
|
||
| const handleChange = ( | ||
| _scopeId: string, | ||
|
|
@@ -106,51 +108,52 @@ const NotificationGroupSettings = ({ groupId }: Props) => { | |
| setValues((prev: Record<string, unknown>) => ({ ...prev, [key]: value })); | ||
| }; | ||
|
|
||
| // Each `collapsible_switch` field id is a notification id; only role-type | ||
| // notifications carry a recipients child. Posts only this group's ids — the | ||
| // backend merges them over the stored option, preserving the other groups. | ||
| // Each `collapsible_switch` field id is a record id; its children | ||
| // (`<id>_message`, `<id>_recipients`, or `<id>::<key>`) are folded back into a | ||
| // nested entry keyed by the part after the id + separator. Posts only this | ||
| // group's ids — the backend merges them over the stored option. | ||
| const handleSave = async (): Promise<void> => { | ||
| if (!schema) { | ||
| return; | ||
| } | ||
|
|
||
| const cur = values; | ||
| const roleIds = new Set<string>( | ||
| schema | ||
| .filter((el: SettingsElement) => el.variant === 'multicheck') | ||
| .map((el: SettingsElement) => String(el.field_group_id)) | ||
| ); | ||
|
|
||
| const payload: SavePayload = {}; | ||
|
|
||
| for (const el of schema) { | ||
| if (el.variant !== 'collapsible_switch') { | ||
| continue; | ||
| } | ||
|
|
||
| const id = el.id; | ||
| const message = cur[`${id}${SEP}message`]; | ||
| const entry: SavePayloadEntry = { | ||
| enabled: Boolean(cur[id]), | ||
| message: typeof message === 'string' ? message : '', | ||
| }; | ||
|
|
||
| if (roleIds.has(id)) { | ||
| const recipients = cur[`${id}${SEP}recipients`]; | ||
| entry.recipients = Array.isArray(recipients) | ||
| ? (recipients as string[]) | ||
| : []; | ||
| const entry: Record<string, unknown> = { enabled: Boolean(cur[id]) }; | ||
|
|
||
| for (const child of schema) { | ||
| if (child.field_group_id !== id || child.variant === 'info') { | ||
| continue; | ||
| } | ||
|
|
||
| const childId = String(child.id); | ||
| const key = ( | ||
| childId.startsWith(id) ? childId.slice(id.length) : childId | ||
| ).replace(/^(::|_)/, ''); | ||
|
|
||
| let value = cur[childId]; | ||
| if (child.variant === 'multicheck') { | ||
| value = Array.isArray(value) ? value : []; | ||
| } else if (key === 'message') { | ||
| value = typeof value === 'string' ? value : ''; | ||
| } | ||
|
|
||
| entry[key] = value; | ||
| } | ||
|
|
||
| payload[id] = entry; | ||
| } | ||
|
|
||
| setSaving(true); | ||
| try { | ||
| await apiFetch({ | ||
| path: '/texty/v1/notifications', | ||
| method: 'POST', | ||
| data: payload, | ||
| }); | ||
| await apiFetch({ path: postPath, method: 'POST', data: payload }); | ||
| toast.success(__('Changes saved.', 'texty')); | ||
| } catch (err) { | ||
| console.error('Failed to save notifications', err); | ||
|
|
@@ -169,15 +172,24 @@ const NotificationGroupSettings = ({ groupId }: Props) => { | |
| } | ||
|
|
||
| // Heading is rendered here (plugin-ui's built-in heading has no icon slot). | ||
| // Add-ons can register icons/logos for their own groups via these filters. | ||
| const page = schema.find((el: SettingsElement) => el.type === 'page'); | ||
| const HeaderIcon = ICON_MAP[String(page?.icon ?? '')] ?? Bell; | ||
| const logoFile = LOGO_MAP[String(page?.id ?? '')]; | ||
| const iconMap = applyFilters( | ||
| 'texty_notification_icon_map', | ||
| ICON_MAP | ||
| ) as Record<string, ComponentType<{ className?: string }>>; | ||
| const logoMap = applyFilters( | ||
| 'texty_notification_logo_map', | ||
| LOGO_MAP | ||
| ) as Record<string, string>; | ||
| const HeaderIcon = iconMap[String(page?.icon ?? '')] ?? Bell; | ||
| const logoFile = logoMap[String(page?.id ?? '')]; | ||
| const title = page?.label ?? ''; | ||
| const description = page?.description ?? ''; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-2 bg-white rounded-lg"> | ||
| <div className="flex items-center gap-3 px-6 pt-6"> | ||
| <div className="flex items-center gap-3 px-6 py-4"> | ||
| <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-gray-100"> | ||
| {logoFile ? ( | ||
| <img | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Make the public props contract impossible to misuse.
schemaPathandsavePathare optional independently here, so<NotificationGroupSettings schemaPath="/foo" />still saves to/texty/v1/notifications, and<NotificationGroupSettings />fetches/texty/v1/notifications/schema?group=. Sincesrc/components/index.tsxnow exports this as add-on API, these invalid combinations should be rejected up front instead of failing against the wrong endpoint.Proposed fix
Also applies to: 60-63, 155-157
🤖 Prompt for AI Agents