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
File renamed without changes
Binary file added assets/images/gateways/common-sms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
17 changes: 17 additions & 0 deletions includes/Admin/Menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ public function render_page() {
public function enqueue_scripts() {
$asset_file = include TEXTY_DIR . '/dist/index.asset.php';

// Reusable components bundle (window.texty.components). Registered so
// add-ons that depend on the `texty-components` handle (via webpack
// dependency extraction) get it loaded automatically. Depends on
// `texty-admin` so it merges onto window.texty *after* the localized
// `var texty = {…}` data, rather than being clobbered by it.
if ( file_exists( TEXTY_DIR . '/dist/components.asset.php' ) ) {
$components_asset = include TEXTY_DIR . '/dist/components.asset.php';

wp_register_script(
'texty-components',
TEXTY_URL . '/dist/components.js',
array_merge( $components_asset['dependencies'], [ 'texty-admin' ] ),
$components_asset['version'],
true
);
}

wp_register_script(
'texty-admin',
TEXTY_URL . '/dist/index.js',
Expand Down
2 changes: 1 addition & 1 deletion includes/Gateways/Clickatell.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function description() {
* @return string
*/
public function logo() {
return TEXTY_URL . '/assets/images/clickatell-logo.png';
return TEXTY_URL . '/assets/images/gateways/clickatell-logo.png';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/Gateways/Fake.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function description() {
* @return string
*/
public function logo() {
return TEXTY_URL . '/assets/images/logo.svg';
return TEXTY_URL . '/assets/images/gateways/common-sms.png';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/Gateways/Plivo.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function description() {
* @return string
*/
public function logo() {
return TEXTY_URL . '/assets/images/plivo-logo.png';
return TEXTY_URL . '/assets/images/gateways/plivo-logo.png';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/Gateways/Twilio.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function description() {
* @return string
*/
public function logo() {
return TEXTY_URL . '/assets/images/twilio-logo.png';
return TEXTY_URL . '/assets/images/gateways/twilio-logo.png';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/Gateways/Vonage.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function name() {
* @return string
*/
public function logo() {
return TEXTY_URL . '/assets/images/vonage-logo.png';
return TEXTY_URL . '/assets/images/gateways/vonage-logo.png';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/components/index.tsx
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';
112 changes: 62 additions & 50 deletions src/pages/notifications/components/NotificationGroupSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
Expand All @@ -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;
};
Comment on lines 42 to 48

Copy link
Copy Markdown

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.

schemaPath and savePath are optional independently here, so <NotificationGroupSettings schemaPath="/foo" /> still saves to /texty/v1/notifications, and <NotificationGroupSettings /> fetches /texty/v1/notifications/schema?group=. Since src/components/index.tsx now exports this as add-on API, these invalid combinations should be rejected up front instead of failing against the wrong endpoint.

Proposed fix
-type Props = {
-  // Built-in notifications group (renders /notifications/schema?group=…).
-  groupId?: string;
-  // Or point at any endpoint returning { schema, values } (e.g. Texty Pro's
-  // Automations), with a matching save endpoint.
-  schemaPath?: string;
-  savePath?: string;
-};
+type Props =
+  | {
+      // Built-in notifications group (renders /notifications/schema?group=…).
+      groupId: string;
+      schemaPath?: never;
+      savePath?: never;
+    }
+  | {
+      // Custom feature endpoints must be provided as a pair.
+      groupId?: never;
+      schemaPath: string;
+      savePath: string;
+    };

Also applies to: 60-63, 155-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/notifications/components/NotificationGroupSettings.tsx` around
lines 42 - 49, Make NotificationGroupSettings’ public props contract mutually
exclusive so invalid combinations cannot compile: in NotificationGroupSettings,
replace the current Props shape with a union that allows either the built-in
groupId flow or the custom endpoint flow, and require schemaPath and savePath
together for the custom case. Update the component’s prop typing and any related
call sites/helpers in NotificationGroupSettings so <NotificationGroupSettings
schemaPath="..." /> and bare <NotificationGroupSettings /> are rejected unless
they provide the correct paired fields.


// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 schemaPath/savePath without a groupId gets key={undefined}. Switching between two custom schemas will reuse the same <Settings> instance and carry its internal dirty/form state across datasets.

Proposed fix
-        key={groupId}
+        key={fetchPath}

Also applies to: 215-216

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/notifications/components/NotificationGroupSettings.tsx` around
lines 55 - 63, The Settings form key is currently derived only from groupId in
NotificationGroupSettings, so custom consumers using schemaPath/savePath without
a groupId can reuse the same instance and leak form state between endpoints.
Update the keying logic in NotificationGroupSettings to include the resolved
endpoint identity from fetchPath and postPath, and keep the existing
groupId-based key only as part of that composite so different schemas/save
targets mount distinct Settings instances.

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
Expand All @@ -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;
}
Expand All @@ -96,7 +98,7 @@ const NotificationGroupSettings = ({ groupId }: Props) => {
return () => {
cancelled = true;
};
}, [groupId]);
}, [fetchPath]);

const handleChange = (
_scopeId: string,
Expand All @@ -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);
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/pages/notifications/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { toast } from '@wedevs/plugin-ui';
import apiFetch from '@wordpress/api-fetch';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';

import type {
NotificationItem,
Expand Down Expand Up @@ -51,7 +52,7 @@ export const useNotifications = (): UseNotifications => {
const load = async (): Promise<void> => {
try {
const response = await apiFetch<NotificationsResponse>({
path: '/texty/v1/notifications?context=edit',
path: addQueryArgs('/texty/v1/notifications', { context: 'edit' }),
method: 'GET',
});
if (cancelled) {
Expand Down
82 changes: 63 additions & 19 deletions src/pages/notifications/index.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,79 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@wedevs/plugin-ui';
import { useMemo } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
import { type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';

import Integrations from './Integrations';
import Settings from './Settings';
import UserEvents from './UserEvents';

type NotificationTab = {
value: string;
label: string;
content: ReactNode;
};

const TAB_PARAM = 'tab';

const Notifications = () => {
const [searchParams, setSearchParams] = useSearchParams();

const tabs = useMemo<NotificationTab[]>(
() => [
{
value: 'user-events',
label: __('User Events', 'texty'),
content: <UserEvents />,
},
{
value: 'integrations',
label: __('Integrations', 'texty'),
content: <Integrations />,
},
...(applyFilters('texty_notifications_tabs', []) as NotificationTab[]),
{
value: 'settings',
label: __('Settings', 'texty'),
content: <Settings />,
},
],
[],
);

const requested = searchParams.get(TAB_PARAM);
const activeTab = tabs.some((tab) => tab.value === requested)
? (requested as string)
: (tabs[0]?.value ?? '');

const handleTabChange = (value: string): void => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set(TAB_PARAM, value);
return next;
},
{ replace: true },
);
};

return (
<div className="flex flex-col gap-6">
<Tabs defaultValue="user-events">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList className="bg-[#E2E2E7]">
<TabsTrigger value="user-events">
{__('User Events', 'texty')}
</TabsTrigger>
<TabsTrigger value="integrations">
{__('Integrations', 'texty')}
</TabsTrigger>
<TabsTrigger value="settings">{__('Settings', 'texty')}</TabsTrigger>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>

<TabsContent value="user-events" className="mt-4">
<UserEvents />
</TabsContent>

<TabsContent value="integrations" className="mt-4">
<Integrations />
</TabsContent>

<TabsContent value="settings" className="mt-4">
<Settings />
</TabsContent>
{tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="mt-4">
{tab.content}
</TabsContent>
))}
</Tabs>
</div>
);
Expand Down
Loading
Loading