feat(sub): add template variables to subscription metadata (#6163)

This commit is contained in:
isultanov99
2026-08-15 17:38:29 +02:00
committed by GitHub
parent 8c8556ab32
commit 2d669fa4b7
22 changed files with 457 additions and 108 deletions
@@ -1,10 +1,11 @@
import { useRef } from 'react';
import { Button, Input, Popover, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { CodeOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
interface RemarkTemplateFieldProps {
@@ -13,19 +14,31 @@ interface RemarkTemplateFieldProps {
onChange?: (value: string) => void;
maxLength?: number;
placeholder?: string;
multiline?: boolean;
rows?: number;
metadataOnly?: boolean;
}
/**
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
* (insert-at-caret) and a live, sample-based preview of the expanded result.
* Used for the global subscription Remark Template.
* Used for subscription text fields that support Remark Template variables.
*/
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
const { t } = useTranslation();
const inputRef = useRef<InputRef>(null);
const textAreaRef = useRef<TextAreaRef>(null);
const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
function getTextElement() {
if (multiline) {
return textAreaRef.current?.resizableTextArea?.textArea ?? null;
}
return inputRef.current?.input ?? null;
}
function insertToken(token: string) {
const el = inputRef.current?.input;
const el = getTextElement();
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const insert = wrapToken(token);
@@ -39,31 +52,47 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
});
}
const pickerButton = (
<Popover
content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
</Tooltip>
</Popover>
);
return (
<div>
<Input
ref={inputRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
suffix={
<Popover
content={<RemarkVarPicker onPick={insertToken} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
</Tooltip>
</Popover>
}
/>
{multiline ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea
ref={textAreaRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange?.(e.target.value)}
/>
{pickerButton}
</div>
) : (
<Input
ref={inputRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
suffix={pickerButton}
/>
)}
{hasRemarkTokens(value) && (
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
{t('pages.hosts.remarkVars.preview')}:{' '}
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
</div>
)}
</div>
@@ -2,31 +2,33 @@ import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
import type { RemarkVar } from '@/lib/remark/remarkVariables';
import { activateOnKey } from '@/utils/a11y';
interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
onPick: (token: string) => void;
variables?: RemarkVar[];
}
/**
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
* the global remark-template field.
*/
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
const { t } = useTranslation();
return (
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.hosts.remarkVars.intro')}
</Typography.Paragraph>
{REMARK_VAR_GROUPS.map((group) => (
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
<div key={group} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
{t(`pages.hosts.remarkVars.groups.${group}`)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
{variables.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag
role="button"