Skip to content
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

feat(dashboard): variable management for all editor fields #7379

Merged
merged 90 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
997c1f8
fix(api): Allow arbitrary variables on the payload namespace
SokratisVidros Dec 23, 2024
feaf04c
wup:
scopsy Dec 23, 2024
163ad78
fix: items
scopsy Dec 23, 2024
b2111ec
Update email-subject.tsx
scopsy Dec 23, 2024
65733c8
fix: hello world
scopsy Dec 23, 2024
624383a
fix: re order
scopsy Dec 23, 2024
75d2c7d
fix: state
scopsy Dec 23, 2024
faa410b
fix: refactor field editor
scopsy Dec 23, 2024
96acb70
j
scopsy Dec 23, 2024
80ed1f3
fix: update other field
scopsy Dec 23, 2024
6ec81ab
fix: refactor
scopsy Dec 23, 2024
25141c8
fix: item
scopsy Dec 23, 2024
c32cd7e
fix:
scopsy Dec 23, 2024
2c3e6a9
fix: cursor
scopsy Dec 23, 2024
bf9324a
Update field-editor.tsx
scopsy Dec 23, 2024
fefc2cc
fix: a
scopsy Dec 24, 2024
96ffc1b
Merge branch 'next' into pills-for-all-inputs
scopsy Dec 25, 2024
ed3eaae
Merge branch 'next' into pills-for-all-inputs
scopsy Dec 27, 2024
c6be4ab
Merge branch 'next' into pills-for-all-inputs
scopsy Dec 30, 2024
ec924ed
fix: open prs
scopsy Dec 30, 2024
e717bc8
fix: design
scopsy Dec 30, 2024
e56a5c6
fix:
scopsy Dec 30, 2024
c83af5c
Update variable-popover.tsx
scopsy Dec 30, 2024
93c1d46
fix: it worked
scopsy Dec 30, 2024
50a25c9
fix: refactor
scopsy Dec 30, 2024
74b7ba4
fix: done
scopsy Dec 30, 2024
e78ee36
Merge branch 'next' into pills-for-all-inputs
scopsy Dec 30, 2024
b378aac
fix: add some personality
scopsy Dec 30, 2024
c0f0c3e
fix: view
scopsy Dec 30, 2024
05842f0
Merge branch 'pills-for-all-inputs' of https://github.com/novuhq/novu…
scopsy Dec 30, 2024
48b448c
fix: popover
scopsy Dec 30, 2024
62c48c2
fixes: asdas
scopsy Dec 30, 2024
ecc8a8c
fix: reusability
scopsy Dec 30, 2024
fb0901f
fix: refactor
scopsy Dec 30, 2024
95f249b
feat: add comments
scopsy Dec 30, 2024
92f915f
fix: types
scopsy Dec 30, 2024
f97c433
fix: remove unused
scopsy Dec 30, 2024
44c678a
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 1, 2025
2bd08b0
Update field-editor.tsx
scopsy Jan 1, 2025
a20ee80
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 2, 2025
7209172
fix: close on blur
scopsy Jan 2, 2025
64564ef
fix: refactor
scopsy Jan 3, 2025
d00d441
fix: working state
scopsy Jan 3, 2025
36da57b
fix: auto complete
scopsy Jan 3, 2025
c10c935
fix: liquid
scopsy Jan 3, 2025
d355467
fix: pr comments
scopsy Jan 3, 2025
a148965
fix: initial values
scopsy Jan 3, 2025
71d6d08
Update variable-pill-widget.ts
scopsy Jan 3, 2025
d1d512a
fix: items
scopsy Jan 3, 2025
17164ed
fix: popover
scopsy Jan 3, 2025
bda722a
fix: command refactor
scopsy Jan 3, 2025
cc3b33b
improve usememo
scopsy Jan 3, 2025
a8c1816
fix: revie
scopsy Jan 3, 2025
967be19
fix: minor issues
scopsy Jan 3, 2025
dc7d907
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 3, 2025
5957c02
fix: excess padding
scopsy Jan 6, 2025
c064171
fix: gap
scopsy Jan 6, 2025
481607c
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 7, 2025
b7d338e
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 10, 2025
9f389f2
fix: style
scopsy Jan 10, 2025
132f5d2
fix: pr
scopsy Jan 12, 2025
6e7d175
Update in-app-action-dropdown.tsx
scopsy Jan 12, 2025
c0e01ba
feat: rename control input
scopsy Jan 12, 2025
a1bad34
fix: prop nmae
scopsy Jan 12, 2025
429e6a9
fix: rename
scopsy Jan 12, 2025
d480ffb
fix: cinsole errors
scopsy Jan 12, 2025
ebcb51e
fix: normalize vars
scopsy Jan 12, 2025
a6ac187
fix: renames
scopsy Jan 12, 2025
18ee766
fix: content after form
scopsy Jan 12, 2025
ae875e1
fix: renames
scopsy Jan 12, 2025
70f13eb
fix: filters
scopsy Jan 12, 2025
80f8ae4
fix: parser
scopsy Jan 12, 2025
8555101
fix: refactor
scopsy Jan 12, 2025
8133c4f
fix: build
scopsy Jan 12, 2025
d290268
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 12, 2025
5ca2247
Update .cspell.json
scopsy Jan 12, 2025
73b7b5f
Update variable-theme.ts
scopsy Jan 12, 2025
f3ebe5c
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 13, 2025
2e25fd4
fixed: pr notes
scopsy Jan 13, 2025
4ce0cdc
fix: input size
scopsy Jan 13, 2025
93d7641
fix: drop position
scopsy Jan 13, 2025
ba60a17
Update variable-popover.tsx
scopsy Jan 13, 2025
69c48b5
fix: position
scopsy Jan 13, 2025
4525a00
fix: add save button
scopsy Jan 13, 2025
d15d01a
fix: done
scopsy Jan 13, 2025
78aba64
fix: add item tracking
scopsy Jan 13, 2025
f153b42
add filter preview
scopsy Jan 13, 2025
ea753c2
Update variable-theme.ts
scopsy Jan 14, 2025
707f7ba
Merge branch 'next' into pills-for-all-inputs
scopsy Jan 14, 2025
e84db18
Update variable-popover.tsx
scopsy Jan 14, 2025
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: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@
"tsconfig.json",
"unreadRead",
"websockets",
"apps/dashboard/src/components/header-navigation/customer-support-button.tsx"
"apps/dashboard/src/components/header-navigation/customer-support-button.tsx",
"apps/dashboard/src/components/primitives/field-editor/variable-popover/constants.ts"
]
}
20 changes: 20 additions & 0 deletions apps/dashboard/public/images/code.svg
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to add it since I need to reference it from ::after in css

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
238 changes: 238 additions & 0 deletions apps/dashboard/src/components/primitives/field-editor/field-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { EditorView } from '@uiw/react-codemirror';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about calling this ControlField or ControlInput? I prefer the latter as it is an input for Novu Controls. It maps better with the domain.

import { Completion, CompletionContext } from '@codemirror/autocomplete';
import { autocompletion } from '@codemirror/autocomplete';

import { Editor } from '@/components/primitives/editor';
import { completions } from '@/utils/liquid-autocomplete';
import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables';
import { Popover, PopoverTrigger } from '@/components/primitives/popover';
import { createVariablePlugin } from './variable-plugin';
import { VariablePopover } from './variable-popover';
import { variablePillTheme } from './variable-plugin/variable-theme';
import { useCallback, useMemo, useRef, useState, Dispatch, SetStateAction } from 'react';

type SelectedVariable = {
value: string;
from: number;
to: number;
} | null;

type CompletionRange = {
scopsy marked this conversation as resolved.
Show resolved Hide resolved
from: number;
to: number;
} | null;

type FieldEditorProps = {
value: string;
onChange: (value: string) => void;
variables: LiquidVariable[];
placeholder?: string;
autoFocus?: boolean;
size?: 'default' | 'lg';
fontFamily?: 'inherit';
id?: string;
singleLine?: boolean;
scopsy marked this conversation as resolved.
Show resolved Hide resolved
indentWithTab?: boolean;
};

export function FieldEditor({
scopsy marked this conversation as resolved.
Show resolved Hide resolved
scopsy marked this conversation as resolved.
Show resolved Hide resolved
value,
onChange,
variables,
placeholder,
autoFocus,
size = 'default',
fontFamily = 'inherit',
id,
singleLine,
indentWithTab,
}: FieldEditorProps) {
const viewRef = useRef<EditorView | null>(null);
const lastCompletionRef = useRef<CompletionRange>(null);

const { selectedVariable, setSelectedVariable, handleVariableSelect, isUpdatingRef } = useVariableSelection();

const handleVariableUpdate = useVariableUpdate(
scopsy marked this conversation as resolved.
Show resolved Hide resolved
selectedVariable,
viewRef,
isUpdatingRef,
onChange,
setSelectedVariable
);
scopsy marked this conversation as resolved.
Show resolved Hide resolved

const completionSource = useCompletionSource(variables, lastCompletionRef);
scopsy marked this conversation as resolved.
Show resolved Hide resolved

const extensions = useMemo(
() => [
autocompletion({
scopsy marked this conversation as resolved.
Show resolved Hide resolved
override: [completionSource],
closeOnBlur: false,
defaultKeymap: true,
activateOnTyping: true,
}),
EditorView.lineWrapping,
variablePillTheme,
createVariablePlugin({ viewRef, lastCompletionRef, onSelect: handleVariableSelect }),
],
[variables, completionSource, handleVariableSelect]
);

return (
<div className="relative">
<Editor
singleLine={singleLine}
indentWithTab={indentWithTab}
size={size}
className="flex-1"
autoFocus={autoFocus}
fontFamily={fontFamily}
placeholder={placeholder}
id={id}
extensions={extensions}
value={value}
onChange={onChange}
/>
<Popover
open={!!selectedVariable}
onOpenChange={(open) => {
if (!open) {
setTimeout(() => setSelectedVariable(null), 0);
}
}}
>
<PopoverTrigger asChild>
<div />
scopsy marked this conversation as resolved.
Show resolved Hide resolved
</PopoverTrigger>
{selectedVariable && (
<VariablePopover
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the popover jumps, I feel it should be aligned with the start of the variable, but not sure if that is possible

Screen.Recording.2024-12-31.at.10.02.25.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a tricky one, i gave up on this. Will make another try with a clean head :)

variable={selectedVariable.value}
onClose={() => setSelectedVariable(null)}
onUpdate={handleVariableUpdate}
/>
)}
</Popover>
</div>
);
}

/**
* Manages the state and selection of Liquid variables in the editor.
*
* This hook handles the complex state management needed when a user selects a variable in the editor:
* 1. Tracks which variable is currently selected
* 2. Prevents recursive updates when variables are being modified
*/
function useVariableSelection() {
const [selectedVariable, setSelectedVariable] = useState<SelectedVariable>(null);
const isUpdatingRef = useRef(false);

const handleVariableSelect = useCallback((value: string, from: number, to: number) => {
if (isUpdatingRef.current) return;

requestAnimationFrame(() => {
scopsy marked this conversation as resolved.
Show resolved Hide resolved
scopsy marked this conversation as resolved.
Show resolved Hide resolved
setSelectedVariable({ value, from, to });
});
}, []);

return {
selectedVariable,
setSelectedVariable,
handleVariableSelect,
isUpdatingRef,
};
}

/**
* Handles the logic of updating Liquid variables in the editor while maintaining proper syntax.
*
* This hook manages several critical aspects of variable editing:
* 1. Ensures proper Liquid syntax ({{...}}) is maintained when updating variables
* 2. Handles edge cases like existing closing brackets
* 3. Maintains cursor position after updates
* 4. Prevents recursive updates during the edit process
*
* The update process:
* 1. Checks if the new value already has Liquid syntax
* 2. Detects if there are existing closing brackets after the cursor
* 3. Calculates the correct range to replace
* 4. Updates the editor state while maintaining proper cursor position
*/
function useVariableUpdate(
scopsy marked this conversation as resolved.
Show resolved Hide resolved
selectedVariable: SelectedVariable,
viewRef: React.RefObject<EditorView>,
isUpdatingRef: React.MutableRefObject<boolean>,
onChange: (value: string) => void,
setSelectedVariable: Dispatch<SetStateAction<SelectedVariable>>
) {
return useCallback(
(newValue: string) => {
if (!selectedVariable || !viewRef.current || isUpdatingRef.current) return;

try {
isUpdatingRef.current = true;
const { from, to } = selectedVariable;
const view = viewRef.current;

const hasLiquidSyntax = newValue.match(/^\{\{.*\}\}$/);
const newVariableText = hasLiquidSyntax ? newValue : `{{${newValue}}}`;

const currentContent = view.state.doc.toString();
const afterCursor = currentContent.slice(to).trim();
const hasClosingBrackets = afterCursor.startsWith('}}');

const changes = {
from,
to: hasClosingBrackets ? to + 2 : to,
insert: newVariableText,
};

view.dispatch({
changes,
selection: { anchor: from + newVariableText.length },
});

onChange(view.state.doc.toString());

setSelectedVariable((prev: SelectedVariable) =>
prev ? { ...prev, value: newValue, to: from + newVariableText.length } : null
);
} finally {
isUpdatingRef.current = false;
}
},
[selectedVariable, onChange, viewRef, isUpdatingRef, setSelectedVariable]
);
}

function useCompletionSource(variables: LiquidVariable[], lastCompletionRef: React.MutableRefObject<CompletionRange>) {
return useCallback(
(context: CompletionContext) => {
// Match text that starts with {{ and capture everything after it until the cursor position
const word = context.matchBefore(/\{\{([^}]*)/);
scopsy marked this conversation as resolved.
Show resolved Hide resolved
if (!word) return null;

const options = completions(variables)(context);
if (!options) return null;

return {
...options,
apply: (view: EditorView, completion: Completion, from: number, to: number) => {
const text = completion.label;
lastCompletionRef.current = { from, to };

// Handle liquid variable syntax ({{variable}})
// If we're not already inside liquid brackets, wrap the completion with {{}}
// If we're inside liquid brackets (detected by checking previous chars), just insert the variable name
const content = view.state.doc.toString();
const before = content.slice(Math.max(0, from - 2), from);
const insert = before !== '{{' ? `{{${text}}} ` : `${text}}} `;

view.dispatch({
changes: { from, to, insert },
});
},
scopsy marked this conversation as resolved.
Show resolved Hide resolved
};
},
[variables, lastCompletionRef]
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FieldEditor } from './field-editor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const VARIABLE_REGEX = /{{([^{}]+)}}/g;
scopsy marked this conversation as resolved.
Show resolved Hide resolved
export const VARIABLE_PILL_CLASS = 'cm-variable-pill';
export const MODIFIERS_CLASS = 'has-modifiers';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { EditorView, ViewPlugin, Decoration } from '@uiw/react-codemirror';
import type { PluginState } from './types';
import { VariablePluginView } from './plugin-view';

export function createVariablePlugin({ viewRef, lastCompletionRef, onSelect }: PluginState) {
scopsy marked this conversation as resolved.
Show resolved Hide resolved
return ViewPlugin.fromClass(
class {
private view: VariablePluginView;

constructor(view: EditorView) {
this.view = new VariablePluginView(view, viewRef, lastCompletionRef, onSelect);
}

update(update: any) {
this.view.update(update);
}

get decorations() {
return this.view.decorations;
}
},
{
decorations: (v) => v.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none;
}),
}
);
}

export * from './types';
export * from './constants';
scopsy marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { EditorView, Decoration, DecorationSet } from '@uiw/react-codemirror';
import { MutableRefObject } from 'react';
import { VARIABLE_REGEX } from './constants';
import { VariablePillWidget } from './variable-pill-widget';
import { handleVariableBackspace, handleVariableCompletion, isTypingVariable, parseVariable } from './utils';

export class VariablePluginView {
decorations: DecorationSet;
lastCursor: number = 0;
isTypingVariable: boolean = false;

constructor(
view: EditorView,
private viewRef: MutableRefObject<EditorView | null>,
private lastCompletionRef: MutableRefObject<{ from: number; to: number } | null>,
private onSelect?: (value: string, from: number, to: number) => void
) {
this.decorations = this.createDecorations(view);
viewRef.current = view;
}

update(update: any) {
scopsy marked this conversation as resolved.
Show resolved Hide resolved
if (update.docChanged || update.viewportChanged || update.selectionSet) {
const pos = update.state.selection.main.head;
const content = update.state.doc.toString();

this.isTypingVariable = isTypingVariable(content, pos);

// Handle backspace inside a variable
if (update.docChanged && update.changes.desc === 'input.delete.backward') {
if (handleVariableBackspace(update.view, pos, content)) {
return;
}
}

if (update.docChanged) {
handleVariableCompletion(update.view, pos, content);
}

this.decorations = this.createDecorations(update.view);
}

if (update.view) {
this.viewRef.current = update.view;
}
}

createDecorations(view: EditorView) {
const decorations: any[] = [];
const content = view.state.doc.toString();
const pos = view.state.selection.main.head;
let match;

// Iterate through all variable matches in the content and add the pills
while ((match = VARIABLE_REGEX.exec(content)) !== null) {
const { fullVariableName, variableName, start, end, hasModifiers } = parseVariable(match);

// Skip creating pills for variables that are currently being edited
// This allows users to modify variables without the pill getting in the way
if (this.isTypingVariable && pos > start && pos < end) {
continue;
}

if (variableName) {
decorations.push(
Decoration.replace({
widget: new VariablePillWidget(variableName, fullVariableName, start, end, hasModifiers, this.onSelect),
inclusive: false,
side: -1,
}).range(start, end)
);
}
}

this.lastCompletionRef.current = null;

return Decoration.set(decorations, true);
}
}
Loading
Loading