Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
11f69b6
docs(dashboard): add variable text fields implementation plan
Abdulkhalek-1 Jul 8, 2026
8384932
test(dashboard): add jsdom + testing-library client test toolchain
Abdulkhalek-1 Jul 8, 2026
a638b54
feat(dashboard): add variable-field token types and tokenizer
Abdulkhalek-1 Jul 8, 2026
01f8121
feat(dashboard): add unknown-variable detection with suggestions
Abdulkhalek-1 Jul 8, 2026
d26401f
feat(dashboard): add caret insert + active-query helpers
Abdulkhalek-1 Jul 8, 2026
c287830
feat(dashboard): add variable autocomplete filtering
Abdulkhalek-1 Jul 8, 2026
a8a45a1
feat(dashboard): add per-scope variable registry with drift guard
Abdulkhalek-1 Jul 8, 2026
64c0a3a
feat(dashboard): build event-scoped automation variables
Abdulkhalek-1 Jul 8, 2026
eed0528
feat(dashboard): add pure preview template resolver
Abdulkhalek-1 Jul 8, 2026
1381f36
feat(dashboard): add VariableEditor with autocomplete, highlighting, …
Abdulkhalek-1 Jul 8, 2026
a6781a0
fix(dashboard): a11y aria-activedescendant + pristine classes in Vari…
Abdulkhalek-1 Jul 8, 2026
18a8852
feat(dashboard): add searchable VariableBrowser popover
Abdulkhalek-1 Jul 8, 2026
6b1fde6
feat(dashboard): add usePreviewContext hook
Abdulkhalek-1 Jul 8, 2026
e5039ea
feat(dashboard): add Discord-style message/embed preview
Abdulkhalek-1 Jul 8, 2026
a452406
feat(dashboard): barrel export variable-field + i18n chrome keys
Abdulkhalek-1 Jul 8, 2026
e4bddaa
feat(dashboard): variable editor + preview in welcome/farewell/DM
Abdulkhalek-1 Jul 8, 2026
7873a1f
feat(dashboard): variable editor + preview in custom commands
Abdulkhalek-1 Jul 8, 2026
ea32d8a
feat(dashboard): variable editor + preview in scheduled messages
Abdulkhalek-1 Jul 8, 2026
2bd10fd
feat(dashboard): variable editor + preview in leveling announce
Abdulkhalek-1 Jul 8, 2026
9a007a6
feat(dashboard): variable editor + preview in tempvoice name template
Abdulkhalek-1 Jul 8, 2026
a07a698
feat(automation): event-scoped variable editor in action fields
Abdulkhalek-1 Jul 8, 2026
a6a646b
fix(automation): event-scoped vars for step actions + label/id associ…
Abdulkhalek-1 Jul 8, 2026
5ceaef4
feat(automation): Discord preview for message/embed/DM actions
Abdulkhalek-1 Jul 8, 2026
b0c2022
fix(dashboard): single-border VariableEditor overlay + restore token …
Abdulkhalek-1 Jul 8, 2026
7e71b5e
feat(i18n): variable description keys for the variable browser
Abdulkhalek-1 Jul 8, 2026
ff15c6b
fix(deps): sync pnpm-lock.yaml with jsdom + testing-library devDeps
Abdulkhalek-1 Jul 8, 2026
a7fd8d8
fix(dashboard): coverage glob, commands preview gating, aria-required…
Abdulkhalek-1 Jul 8, 2026
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
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,14 @@
"devDependencies": {
"@playwright/test": "^1.49.0",
"@tailwindcss/vite": "^4.1.7",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^4.0.18",
"concurrently": "^9.1.2",
"jsdom": "^29.1.1",
"tailwindcss": "^4.1.7",
"tsx": "^4.21.0",
"typescript": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ import {
SelectTrigger,
SelectValue,
} from "../../../shared/ui/select";
import { VariableEditor } from "../../../shared/ui/variable-field";
import type { VariableDescriptor } from "../../../shared/ui/variable-field";
import type { ActionFieldDescriptor, Channel, Role } from "../../../shared/lib/schemas";

const VARIABLE_FIELD_KEYS = new Set([
"message",
"embed.title",
"embed.description",
"embed.footer",
"webhook.bodyTemplate",
"nickname",
"threadName",
]);

interface ActionFieldsProps {
fields: ActionFieldDescriptor[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
channels: Channel[];
roles: Role[];
variables: VariableDescriptor[];
}

function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
Expand All @@ -36,6 +49,7 @@ export function ActionFields({
onChange,
channels,
roles,
variables,
}: ActionFieldsProps) {
const { t } = useTranslation("common");
return (
Expand Down Expand Up @@ -106,26 +120,52 @@ export function ActionFields({
)}

{field.type === "text" && (
<Input
id={fieldId}
type="text"
aria-required={field.required}
value={String(value)}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
VARIABLE_FIELD_KEYS.has(field.key) ? (
<VariableEditor
id={fieldId}
value={String(value ?? "")}
onChange={(v) => onChange(field.key, v)}
variables={variables}
multiline={false}
aria-required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
) : (
<Input
id={fieldId}
type="text"
aria-required={field.required}
value={String(value)}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
)
)}

{field.type === "textarea" && (
<Textarea
id={fieldId}
aria-required={field.required}
value={String(value)}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
VARIABLE_FIELD_KEYS.has(field.key) ? (
<VariableEditor
id={fieldId}
value={String(value ?? "")}
onChange={(v) => onChange(field.key, v)}
variables={variables}
multiline={true}
aria-required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
) : (
<Textarea
id={fieldId}
aria-required={field.required}
value={String(value)}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
maxLength={field.maxLength}
/>
)
)}

{field.type === "color" && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { TFunction } from "i18next";
import { useChannels } from "../../../shared/hooks/useChannels";
import { useRoles } from "../../../shared/hooks/useRoles";
import { ActionFields } from "../components/ActionFields";
import { buildAutomationVariables, DiscordMessagePreview, usePreviewContext } from "../../../shared/ui/variable-field";
import { Button } from "../../../shared/ui/button";
import { Label } from "../../../shared/ui/label";
import { Input } from "../../../shared/ui/input";
Expand Down Expand Up @@ -45,6 +46,7 @@ interface ActionPanelProps {
action: ActionConfig;
constants: Constants;
guildId: string;
eventType: string;
totalActions: number;
onActionChange: (index: number, action: ActionConfig) => void;
onActionRemove: (index: number) => void;
Expand All @@ -59,6 +61,7 @@ interface StepPanelProps {
steps: RuleStep[];
constants: Constants;
guildId: string;
eventType: string;
onStepChange: (stepId: string, step: RuleStep) => void;
onStepRemove: (stepId: string) => void;
onClose: () => void;
Expand Down Expand Up @@ -236,6 +239,7 @@ function ActionPanel({
action,
constants,
guildId,
eventType,
totalActions,
onActionChange,
onActionRemove,
Expand All @@ -247,6 +251,8 @@ function ActionPanel({
const { data: roles = [] } = useRoles(guildId);
const fields: ActionFieldDescriptor[] =
constants.actionTypeFields[action.type] ?? [];
const variables = buildAutomationVariables(constants, eventType);
const real = usePreviewContext(guildId);

const handleTypeChange = (newType: string) => {
onActionChange(index, { type: newType });
Expand Down Expand Up @@ -302,9 +308,34 @@ function ActionPanel({
onChange={handleFieldChange}
channels={channels}
roles={roles}
variables={variables}
/>
)}

{(action.type === "sendMessage" || action.type === "sendDM") && (
<div className="mt-2">
<DiscordMessagePreview
variables={variables}
real={real}
content={action.message ?? ""}
/>
</div>
)}
{action.type === "sendEmbed" && (
<div className="mt-2">
<DiscordMessagePreview
variables={variables}
real={real}
embed={{
title: action.embed?.title,
description: action.embed?.description,
footer: action.embed?.footer,
color: action.embed?.color,
}}
/>
</div>
)}

{totalActions > 1 && (
<div className="flex gap-2 border-t border-border pt-3">
<Button
Expand Down Expand Up @@ -387,12 +418,14 @@ function StepPanel({
steps,
constants,
guildId,
eventType,
onStepChange,
onStepRemove,
}: StepPanelProps) {
const { t } = useTranslation(["rules", "common"]);
const { data: channels = [] } = useChannels(guildId);
const { data: roles = [] } = useRoles(guildId);
const variables = buildAutomationVariables(constants, eventType);
const step = steps.find((s) => s.id === stepId);
if (!step) return <p className="text-xs text-text-muted">{t("panel.stepNotFound")}</p>;

Expand Down Expand Up @@ -440,6 +473,7 @@ function StepPanel({
onChange={handleFieldChange}
channels={channels}
roles={roles}
variables={variables}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ function WorkflowEditorInner({ rule, draft, onClose }: WorkflowEditorProps) {
action={actions[selectedNode.index]}
constants={constants}
guildId={guildId}
eventType={eventType}
totalActions={actions.length}
onActionChange={rawActionChange}
onActionRemove={actions.length > 1 ? handleActionRemove : handleActionReset}
Expand All @@ -611,6 +612,7 @@ function WorkflowEditorInner({ rule, draft, onClose }: WorkflowEditorProps) {
steps={steps}
constants={constants}
guildId={guildId}
eventType={eventType}
onStepChange={handleStepChange}
onStepRemove={handleStepRemove}
onClose={() => setSelectedNode(null)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import { useTranslation } from "react-i18next";
import { useParams } from "@tanstack/react-router";
import { Icon } from "../../../shared/components/Icon";
import { Button } from "../../../shared/ui/button";
import { Input } from "../../../shared/ui/input";
import { Label } from "../../../shared/ui/label";
import {
VariableEditor,
usePreviewContext,
tempvoiceVariables,
buildTokenValues,
resolveTemplatePreview,
} from "../../../shared/ui/variable-field";
import { Alert } from "../../../shared/ui/alert";
import { Card } from "../../../shared/ui/card";
import {
Expand Down Expand Up @@ -38,6 +44,8 @@ export function TempVoiceForm() {
const updateConfig = useUpdateTempVoice(guildId);
const deleteConfig = useDeleteTempVoice(guildId);

const real = usePreviewContext(guildId);

const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [hubChannelId, setHubChannelId] = useState("");
Expand Down Expand Up @@ -246,16 +254,16 @@ export function TempVoiceForm() {

<div>
<Label htmlFor="tempvoice-name-template">{t("form.nameTemplate")}</Label>
<Input
<VariableEditor
id="tempvoice-name-template"
type="text"
value={nameTemplate}
onChange={(e) => setNameTemplate(e.target.value)}
onChange={setNameTemplate}
variables={tempvoiceVariables}
placeholder={t("form.defaultNameTemplate")}
maxLength={100}
/>
<p className="mt-1 text-xs text-text-muted">
{t("form.nameTemplateHint")}
{t("common:variableField.preview")}: {resolveTemplatePreview(nameTemplate, buildTokenValues(tempvoiceVariables, real))}
</p>
</div>

Expand Down
56 changes: 34 additions & 22 deletions apps/dashboard/src/client/routes/guild/$guildId/commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ import { Button } from "../../../shared/ui/button";
import { Input } from "../../../shared/ui/input";
import { Label } from "../../../shared/ui/label";
import { Card } from "../../../shared/ui/card";
import { Textarea } from "../../../shared/ui/textarea";
import { Switch } from "../../../shared/ui/switch";
import {
VariableEditor,
VariableBrowser,
DiscordMessagePreview,
usePreviewContext,
customCommandVariables,
} from "../../../shared/ui/variable-field";
import { Badge } from "../../../shared/ui/badge";
import { Separator } from "../../../shared/ui/separator";
import { ColorPicker } from "../../../shared/ui/color-picker";
Expand Down Expand Up @@ -70,6 +76,7 @@ export function CommandsPage() {
const updateCommand = useUpdateCustomCommand(guildId);
const deleteCommand = useDeleteCustomCommand(guildId);
const { data: roles } = useRoles(guildId);
const real = usePreviewContext(guildId);

const [dialogOpen, setDialogOpen] = useState(false);
const [editingCommand, setEditingCommand] = useState<CustomCommandItem | null>(null);
Expand Down Expand Up @@ -490,45 +497,45 @@ export function CommandsPage() {
</div>

{formResponseType === "text" ? (
<div>
<div className="space-y-2">
<Label htmlFor="cmd-content">{t("form.message")}</Label>
<Textarea
<VariableEditor
id="cmd-content"
value={formContent}
onChange={(e) => {
setFormContent(e.target.value);
markDirty();
}}
onChange={(v) => { setFormContent(v); markDirty(); }}
placeholder={t("form.message")}
variables={customCommandVariables}
multiline
rows={4}
/>
<VariableBrowser
variables={customCommandVariables}
onInsert={(tok) => { setFormContent(formContent + tok); markDirty(); }}
/>
</div>
) : (
<div className="space-y-3">
<div>
<Label htmlFor="embed-title">{t("form.embedTitle")}</Label>
<Input
<VariableEditor
id="embed-title"
value={formEmbedTitle}
onChange={(e) => {
setFormEmbedTitle(e.target.value);
markDirty();
}}
onChange={(v) => { setFormEmbedTitle(v); markDirty(); }}
placeholder={t("form.embedTitle")}
variables={customCommandVariables}
/>
</div>
<div>
<Label htmlFor="embed-desc">
{t("form.embedDescription")}
</Label>
<Textarea
<VariableEditor
id="embed-desc"
value={formEmbedDescription}
onChange={(e) => {
setFormEmbedDescription(e.target.value);
markDirty();
}}
onChange={(v) => { setFormEmbedDescription(v); markDirty(); }}
placeholder={t("form.embedDescription")}
variables={customCommandVariables}
multiline
rows={3}
/>
</div>
Expand All @@ -547,14 +554,12 @@ export function CommandsPage() {
</div>
<div>
<Label htmlFor="embed-footer">{t("form.footer")}</Label>
<Input
<VariableEditor
id="embed-footer"
value={formEmbedFooter}
onChange={(e) => {
setFormEmbedFooter(e.target.value);
markDirty();
}}
onChange={(v) => { setFormEmbedFooter(v); markDirty(); }}
placeholder={t("form.footer")}
variables={customCommandVariables}
/>
</div>
</div>
Expand Down Expand Up @@ -769,6 +774,13 @@ export function CommandsPage() {
</div>
</div>

<DiscordMessagePreview
variables={customCommandVariables}
real={real}
content={formResponseType === "text" ? formContent : undefined}
embed={formResponseType === "embed" ? { title: formEmbedTitle, description: formEmbedDescription, footer: formEmbedFooter } : undefined}
/>

<div className="flex justify-end gap-2">
<Button
variant="outline"
Expand Down
Loading