import { getManifest } from "@budibase/string-templates" import sanitizeHtml from "sanitize-html" import { groupBy } from "lodash" import { EditorModesMap, Helper, Snippet } from "@budibase/types" import { CompletionContext } from "@codemirror/autocomplete" import { EditorView } from "@codemirror/view" import { BindingCompletion, BindingCompletionOption } from "@/types" export const EditorModes: EditorModesMap = { JS: { name: "javascript", json: false, match: /\$$/, }, Handlebars: { name: "handlebars", base: "text/html", match: /{{[\s]*[\w\s]*/, }, Text: { name: "text/html", }, } const buildHelperInfoNode = (helper: Helper) => { const ele = document.createElement("div") ele.classList.add("info-bubble") const exampleNodeHtml = helper.example ? `
${helper.example}
` : "" const descriptionMarkup = sanitizeHtml(helper.description, { allowedTags: [], allowedAttributes: {}, }) const descriptionNodeHtml = `
${descriptionMarkup}
` ele.innerHTML = ` ${descriptionNodeHtml} ${exampleNodeHtml} ` return ele } const toSpectrumIcon = (name: string) => { return ` ` } const buildSectionHeader = ( type: string, sectionName: string, icon: string, rank: number ) => { const ele = document.createElement("div") ele.classList.add("info-section") if (type) { ele.classList.add(type) } ele.innerHTML = `${toSpectrumIcon(icon)}${sectionName}` return { name: sectionName, header: () => ele, rank, } } const helpersToCompletion = ( helpers: Record, mode: { name: "javascript" | "handlebars" } ): BindingCompletionOption[] => { const helperSection = buildSectionHeader("helper", "Helpers", "Code", 99) return Object.keys(helpers).flatMap(helperName => { const helper = helpers[helperName] return { label: helperName, info: () => buildHelperInfoNode(helper), type: "helper", section: helperSection, detail: "Function", apply: ( view: EditorView, _completion: BindingCompletionOption, from: number, to: number ) => { insertBinding(view, from, to, helperName, mode, AutocompleteType.HELPER) }, } }) } export const getHelperCompletions = (mode: { name: "javascript" | "handlebars" }): BindingCompletionOption[] => { // TODO: manifest needs to be properly typed const manifest: any = getManifest() return Object.keys(manifest).flatMap(key => { return helpersToCompletion(manifest[key], mode) }) } export const snippetAutoComplete = (snippets: Snippet[]): BindingCompletion => { return setAutocomplete( snippets.map(snippet => ({ section: buildSectionHeader("snippets", "Snippets", "Code", 100), label: `snippets.${snippet.name}`, displayLabel: snippet.name, })) ) } const bindingFilter = (options: BindingCompletionOption[], query: string) => { return options.filter(completion => { const section_parsed = completion.section?.toString().toLowerCase() const label_parsed = completion.label.toLowerCase() const query_parsed = query.toLowerCase() return ( section_parsed?.includes(query_parsed) || label_parsed.includes(query_parsed) ) }) } export const hbAutocomplete = ( baseCompletions: BindingCompletionOption[] ): BindingCompletion => { function coreCompletion(context: CompletionContext) { let bindingStart = context.matchBefore(EditorModes.Handlebars.match) let options = baseCompletions || [] if (!bindingStart) { return null } // Accommodate spaces const match = bindingStart.text.match(/{{[\s]*/) if (!match) { return null } const query = bindingStart.text.replace(match[0], "") let filtered = bindingFilter(options, query) return { from: bindingStart.from + match[0].length, filter: false, options: filtered, } } return coreCompletion } function wrappedAutocompleteMatch(context: CompletionContext) { return context.matchBefore(/\$\("[\s\w]*/) } export const jsAutocomplete = ( baseCompletions: BindingCompletionOption[] ): BindingCompletion => { function coreCompletion(context: CompletionContext) { let jsBinding = wrappedAutocompleteMatch(context) let options = baseCompletions || [] if (jsBinding) { // Accommodate spaces const match = jsBinding.text.match(/\$\("[\s]*/) if (!match) { return null } const query = jsBinding.text.replace(match[0], "") let filtered = bindingFilter(options, query) return { from: jsBinding.from + match[0].length, filter: false, options: filtered, } } return null } return coreCompletion } export const jsHelperAutocomplete = ( baseCompletions: BindingCompletionOption[] ): BindingCompletion => { return setAutocomplete( baseCompletions.map(helper => ({ ...helper, displayLabel: helper.label, label: `helpers.${helper.label}()`, })) ) } function setAutocomplete( options: BindingCompletionOption[] ): BindingCompletion { return function (context: CompletionContext) { if (wrappedAutocompleteMatch(context)) { return null } const word = context.matchBefore(/\b\w*(\.\w*)?/) if (!word || (word.from == word.to && !context.explicit)) { return null } return { from: word.from, options, } } } const buildBindingInfoNode = (binding: { valueHTML: string value: string | null }) => { if (!binding.valueHTML || binding.value == null) { return null } const ele = document.createElement("div") ele.classList.add("info-bubble") ele.innerHTML = `
${binding.valueHTML}
` return ele } // Readdress these methods. They shouldn't be used export const hbInsert = ( value: string, from: number, to: number, text: string ) => { let parsedInsert = "" const left = from ? value.substring(0, from) : "" const right = to ? value.substring(to) : "" if (!left.includes("{{") || !right.includes("}}")) { parsedInsert = `{{ ${text} }}` } else { parsedInsert = ` ${text} ` } return parsedInsert } export function jsInsert( value: string, from: number, to: number, text: string, { helper, disableWrapping, }: { helper?: boolean; disableWrapping?: boolean } = {} ) { let parsedInsert = "" const left = from ? value.substring(0, from) : "" const right = to ? value.substring(to) : "" if (disableWrapping) { parsedInsert = text } else if (helper) { parsedInsert = `helpers.${text}()` } else if (!left.includes('$("') || !right.includes('")')) { parsedInsert = `$("${text}")` } else { parsedInsert = text } return parsedInsert } const enum AutocompleteType { BINDING, HELPER, TEXT, } // Autocomplete apply behaviour const insertBinding = ( view: EditorView, from: number, to: number, text: string, mode: { name: "javascript" | "handlebars" }, type: AutocompleteType ) => { let parsedInsert if (mode.name == "javascript") { parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text, { helper: type === AutocompleteType.HELPER, disableWrapping: type === AutocompleteType.TEXT, }) } else if (mode.name == "handlebars") { parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) } else { console.warn("Unsupported") return } let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/ let sliced = view.state.doc?.toString().slice(to) const rightBrace = sliced.match(bindingClosePattern) let cursorPos = from + parsedInsert.length if (rightBrace) { cursorPos = from + parsedInsert.length + rightBrace[0].length } view.dispatch({ changes: { from, to, insert: parsedInsert, }, selection: { anchor: cursorPos, }, }) } // TODO: typing in this function isn't great export const bindingsToCompletions = ( bindings: any, mode: { name: "javascript" | "handlebars" } ): BindingCompletionOption[] => { const bindingByCategory = groupBy(bindings, "category") const categoryMeta = bindings?.reduce((acc: any, ele: any) => { acc[ele.category] = acc[ele.category] || {} if (ele.icon) { acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon } if (typeof ele.display?.rank == "number") { acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank } return acc }, {}) const completions = Object.keys(bindingByCategory).reduce< BindingCompletionOption[] >((comps, catKey) => { const { icon, rank } = categoryMeta[catKey] || {} const bindingSectionHeader = buildSectionHeader( // @ts-ignore something wrong with this - logically this should be dictionary bindingByCategory.type, catKey, icon || "", typeof rank == "number" ? rank : 1 ) comps.push( ...bindingByCategory[catKey].reduce( (acc, binding) => { let displayType = binding.fieldSchema?.type || binding.display?.type acc.push({ label: binding.display?.name || binding.readableBinding || "NO NAME", info: () => buildBindingInfoNode(binding), type: "binding", detail: displayType, section: bindingSectionHeader, apply: ( view: EditorView, _completion: BindingCompletionOption, from: number, to: number ) => { insertBinding( view, from, to, binding.readableBinding, mode, AutocompleteType.BINDING ) }, }) return acc }, [] ) ) return comps }, []) return completions }