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

Open
wants to merge 58 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 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
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"
]
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ActionStepEnum, actionStepSchemas, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal';
import { JSONSchema } from 'json-schema-to-ts';
import { StepTypeEnum } from '@novu/shared';
import { JSONSchema } from 'json-schema-to-ts';

export function computeResultSchema(stepType: StepTypeEnum, payloadSchema?: JSONSchema) {
const mapStepTypeToResult: Record<ChannelStepEnum & ActionStepEnum, JSONSchema> = {
Expand Down Expand Up @@ -31,9 +31,13 @@ function buildDigestResult(payloadSchema?: JSONSchema) {
time: {
type: 'string',
},
payload: payloadSchema || {
type: 'object',
},
payload:
payloadSchema && typeof payloadSchema === 'object'
? { ...payloadSchema, additionalProperties: true }
: {
type: 'object',
additionalProperties: true,
},
},
required: ['id', 'time', 'payload'],
additionalProperties: false,
Expand Down
6 changes: 2 additions & 4 deletions apps/api/src/app/workflows-v2/util/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import difference from 'lodash/difference';
import flatMap from 'lodash/flatMap';
import isArray from 'lodash/isArray';
import isObject from 'lodash/isObject';
import reduce from 'lodash/reduce';
import set from 'lodash/set';
import values from 'lodash/values';
import isObject from 'lodash/isObject';
import isArray from 'lodash/isArray';

import { BadRequestException } from '@nestjs/common';

import { JSONSchemaDto } from '@novu/shared';

Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"flat": "^6.0.1",
"js-cookie": "^3.0.5",
"launchdarkly-react-client-sdk": "^3.3.2",
"liquidjs": "^10.20.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
Expand Down
20 changes: 20 additions & 0 deletions apps/dashboard/public/images/code.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 8 additions & 8 deletions apps/dashboard/src/components/primitives/command.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import * as React from 'react';

import { cn } from '@/utils/ui';
import { Dialog, DialogContent } from '@/components/primitives/dialog';
import { InputField, inputVariants } from '@/components/primitives/input';
import { cn } from '@/utils/ui';

const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
Expand Down Expand Up @@ -37,9 +37,9 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {

const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<InputField>
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { inputFieldClassName?: string }
>(({ className, inputFieldClassName, ...props }, ref) => (
<InputField className={inputFieldClassName}>
<CommandPrimitive.Input ref={ref} className={cn(inputVariants(), className)} {...props} />
</InputField>
));
Expand Down Expand Up @@ -115,11 +115,11 @@ CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
};
115 changes: 115 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,115 @@
import { autocompletion } from '@codemirror/autocomplete';
import { EditorView } from '@uiw/react-codemirror';

import { Editor } from '@/components/primitives/editor';
import { Popover, PopoverTrigger } from '@/components/primitives/popover';
import { createAutocompleteSource } from '@/utils/liquid-autocomplete';
import { LiquidVariable } from '@/utils/parseStepVariablesToLiquidVariables';
import { useCallback, useMemo, useRef } from 'react';
import { useVariables } from './hooks/use-variables';
import { createVariablePlugin } from './variable-plugin';
import { variablePillTheme } from './variable-plugin/variable-theme';
import { VariablePopover } from './variable-popover';

type CompletionRange = {
from: number;
to: number;
} | null;

type FieldEditorProps = {
value: string;
onChange: (value: string) => void;
variables: LiquidVariable[];
placeholder?: string;
autoFocus?: boolean;
size?: 'default' | 'lg';
id?: string;
singleLine?: boolean;
indentWithTab?: boolean;
};

const baseExtensions = [EditorView.lineWrapping, variablePillTheme];

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',
id,
singleLine,
indentWithTab,
}: FieldEditorProps) {
const viewRef = useRef<EditorView | null>(null);
const lastCompletionRef = useRef<CompletionRange>(null);

const { selectedVariable, setSelectedVariable, handleVariableSelect, handleVariableUpdate } = useVariables(
viewRef,
onChange
);

const completionSource = useMemo(() => createAutocompleteSource(variables), [variables]);

const autocompletionExtension = useMemo(
() =>
autocompletion({
scopsy marked this conversation as resolved.
Show resolved Hide resolved
override: [completionSource],
closeOnBlur: true,
defaultKeymap: true,
activateOnTyping: true,
}),
[completionSource]
);

const variablePlugin = useMemo(
() =>
createVariablePlugin({
viewRef,
lastCompletionRef,
onSelect: handleVariableSelect,
}),
[handleVariableSelect]
);

const extensions = useMemo(
() => [...baseExtensions, autocompletionExtension, variablePlugin],
[autocompletionExtension, variablePlugin]
);

const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setTimeout(() => setSelectedVariable(null), 0);
}
},
[setSelectedVariable]
);

return (
<div className="relative">
<Editor
fontFamily="inherit"
singleLine={singleLine}
indentWithTab={indentWithTab}
size={size}
basicSetup={{
defaultKeymap: true,
}}
className="flex-1"
autoFocus={autoFocus}
placeholder={placeholder}
id={id}
extensions={extensions}
value={value}
onChange={onChange}
/>
<Popover open={!!selectedVariable} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<div />
scopsy marked this conversation as resolved.
Show resolved Hide resolved
</PopoverTrigger>
{selectedVariable && <VariablePopover variable={selectedVariable.value} onUpdate={handleVariableUpdate} />}
</Popover>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { EditorView } from '@uiw/react-codemirror';
import { useCallback, useRef, useState } from 'react';

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

/**
* Manages variable selection and updates in the editor.
*
* This hook combines variable selection and update logic:
* 1. Tracks which variable is currently selected
* 2. Prevents recursive updates when variables are being modified
* 3. Handles proper Liquid syntax maintenance
* 4. Manages cursor position and editor state updates
*/
export function useVariables(viewRef: React.RefObject<EditorView>, onChange: (value: string) => void) {
const [selectedVariable, setSelectedVariable] = useState<SelectedVariable>(null);
const isUpdatingRef = useRef(false);

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

const handleVariableUpdate = useCallback(
(newValue: string) => {
if (!selectedVariable || !viewRef.current || isUpdatingRef.current) return;

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

// Ensure the new value has proper liquid syntax
const hasLiquidSyntax = newValue.match(/^\{\{.*\}\}$/);
const newVariableText = hasLiquidSyntax ? newValue : `{{${newValue}}}`;

// Calculate the actual end position including closing brackets
const currentContent = view.state.doc.toString();
const afterCursor = currentContent.slice(to);
const closingBracketPos = afterCursor.indexOf('}}');
const actualEnd = closingBracketPos >= 0 ? to + closingBracketPos + 2 : to;

const changes = {
from,
to: actualEnd,
insert: newVariableText,
};

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

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

// Update the selected variable with new bounds
setSelectedVariable((prev: SelectedVariable) =>
prev ? { ...prev, value: newValue, to: from + newVariableText.length } : null
);
} finally {
isUpdatingRef.current = false;
}
},
[selectedVariable, onChange, viewRef]
);

return {
selectedVariable,
setSelectedVariable,
handleVariableSelect,
handleVariableUpdate,
isUpdatingRef,
};
}
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) {
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';
Loading
Loading