@silexlabs/grapesjs-data-source
Version:
Grapesjs Data Source
368 lines (340 loc) • 11.8 kB
text/typescript
import { Expression, Field, FieldKind, IDataSource, Options, Token, TypeId, Type } from './types'
import { Editor } from 'grapesjs'
import { getParentByPersistentId, getStateDisplayName, getState } from './model/state'
import { TemplateResult, html } from 'lit'
import { Component } from 'grapesjs'
import { fromStored, getExpressionResultType } from './model/token'
import GraphQL, { GraphQLOptions } from './datasources/GraphQL'
import { getDataSource, getAllDataSources } from './model/dataSourceRegistry'
import { FIXED_TOKEN_ID } from './types'
export const NOTIFICATION_GROUP = 'Data source'
/**
* Get the display name of a field
*/
export function cleanStateName(name: string | null) {
return name?.toLowerCase()
?.replace(/[^a-z0-9:._-]/g, '-')
?.replace(/^[0-9]+/, '-') // HTML attributes cannot start with digits
}
/**
* Get the display type of a field
* For the dropdown in expressions
* @example "String", "String [ ]", "String { }"
*/
function getTypeDisplayName(typeIds: TypeId[], kind: FieldKind | null): string {
const typeIdsStr = typeIds.join(', ').toLowerCase()
return kind === 'list' ? ` (${typeIdsStr}[])` : kind === 'object' ? ` (${typeIdsStr}{})` : ` (${typeIdsStr})`
}
export function getComponentDebug(component: Component): string {
const parent = component.parent()
const parentName = parent?.getName()
const parentTagName = parent?.get('tagName')
const parentDebug = parentName ? `${parentName} (${parentTagName})` : parentTagName
const id = component.cid
const tagName = component.get('tagName')
const classes = component.getClasses()
const classesStr = classes.length ? `.${classes.join('.')}` : ''
const name = component.getName()
return `${parentDebug} > ${name} (${tagName}#${id}${classesStr})`
}
/**
* Concatenate strings to get a desired length string as result
* Exported for tests
*/
export function concatWithLength(desiredNumChars: number, ...strings: string[]): string {
// const diff = desiredNumChars - `${token.label} ${type}`.length
// return `${token.label}${'\xA0'.repeat(diff * 2)} ${type} ${desiredNumChars}`
// Get current string length
const len = strings.reduce((acc, str) => acc + str.length, 0)
const diff = Math.max(desiredNumChars - len, 0)
// Give the fist string the desired length
const [first, ...rest] = strings
const newFirst = first + '\xA0'.repeat(diff)
// Return the concatenated string
return [newFirst, ...rest].join('')
}
/**
* Get the label for a token
* This is mostly about formatting a string for the dropdowns
*/
export function getTokenDisplayName(component: Component, token: Token): string {
switch (token.type) {
case 'property': {
const type = getTypeDisplayName(token.typeIds, token.kind)
return `${token.label} ${type}`
}
case 'filter': return token.label
case 'state':
return getStateDisplayName(component, token)
default:
console.error('Unknown token type (reading type)', token)
throw new Error('Unknown token type')
}
}
/**
* Group tokens by type
* This is used to create the groups in dropdowns
*/
export function groupByType(editor: Editor, component: Component, completion: Token[], expression: Expression): Record<string, Token[]> {
return completion
.reduce((acc, token) => {
let label
switch (token.type) {
case 'filter': label = 'Filters'; break
case 'property': {
if(token.dataSourceId) {
if(expression.length > 0) {
try {
const type = getExpressionResultType(expression, component)
label = type?.label ?? type?.id ?? 'Unknown'
} catch(e) {
// FIXME: notify user
console.error('Error while getting expression result type in groupByType', {expression, component})
label = 'Unknown'
}
} else {
const dataSource = getDataSource(token.dataSourceId)
if(dataSource) {
label = (dataSource as any).label || (dataSource as any).get?.('label') || token.dataSourceId
} else {
console.error('Data source not found', token.dataSourceId)
editor.runCommand('notifications:add', {
type: 'error',
group: NOTIFICATION_GROUP,
message: `Data source not found: ${token.dataSourceId}`,
})
throw new Error(`Data source not found: ${token.dataSourceId}`)
}
}
} else {
label = 'Fields'
}
break
}
case 'state': {
const parent = getParentByPersistentId(token.componentId, component)
const name = parent?.get('tagName') === 'body' ? 'Website' : parent?.getName()
label = name ? `${name}'s states` : 'States'
break
}
default:
console.error('Unknown token type (reading type)', token)
throw new Error('Unknown token type')
}
if (!acc[label]) acc[label] = []
acc[label].push(token)
return acc
}, {} as Record<string, Token[]>)
}
/**
* Create a "fixed" token
* It is a hard coded content with which you can start an expression
*/
export function getFixedToken(value: string): Token {
return {
type: 'property',
propType: 'field',
fieldId: FIXED_TOKEN_ID,
label: 'Fixed value',
kind: 'scalar',
typeIds: ['String'],
options: {
value,
},
optionsForm: () => html`
<label>Value
<input type="text" name="value" .value=${value}>
</label>
`,
}
}
/**
* Convert a token to a string
* This is used to store the token in the component
*/
export function toValue(token: Token): string {
return JSON.stringify({
...token,
})
}
/**
* Convert a token to an option's tag value (json string)
*/
export function toId(token: Token): string {
switch (token.type) {
case 'property': return `property__${token.dataSourceId || ''}__${token.fieldId}__${token.kind}__${token.typeIds.join(',')}`
case 'filter': return `filter____${token.id}`
case 'state': return `state__${token.componentId}__${token.storedStateId}`
default:
console.error('Unknown token type (reading type)', token)
throw new Error('Unknown token type')
}
}
/**
* Revert an option's tag value to a token
* @throws Error if the token type is not found
*/
export function fromString(editor: Editor, id: string, componentId: string | null): Token {
return fromStored(JSON.parse(id), componentId) as Token
}
/**
* Check if a json is an expression, i.e. an array of tokens
*/
export function isExpression(json: unknown): boolean {
if(typeof json === 'string') throw new Error('json must be parsed')
if (!Array.isArray(json)) return false
return json.every(token => {
if (typeof token !== 'object') return false
if (!token.type) return false
switch (token.type) {
case 'property': {
if (!token.fieldId) return false
if (token.fieldId === FIXED_TOKEN_ID) {
if (!token.options?.value) return false
}
break
}
case 'state': {
if (!token.componentId) return false
if (!token.storedStateId) return false
break
}
case 'filter': {
if (!token.id) return false
break
}
}
return true
})
}
/**
* Convert a json to an expression
*/
export function toExpression(json: unknown | string): Expression | null {
try {
if(typeof json === 'string') json = JSON.parse(json)
if(isExpression(json)) return json as Expression
return null
} catch(e) {
return null
}
}
/**
* Apply a kind to a field
*/
export function convertKind(field: Field | null, from: FieldKind, to: FieldKind): Field | null {
if (!field) {
return null
}
if (field.kind !== from) {
console.error(`Field is not a ${from}`, field)
throw new Error(`Field ${field.label} is not a ${from}`)
}
return {
...field,
kind: to,
}
}
/**
* Get the type of a field, as provided by the data source
* @throws Error if the field has a token with an unknown type
*/
export function getFieldType(editor: Editor, field: Field | null, key: string | undefined, componentId: string | null): Field | null {
if (!field || !key) return null
const allDataSources = getAllDataSources()
const dataSource = allDataSources.find((ds: IDataSource) => ds.id === field.dataSourceId)
if (!dataSource?.isConnected()) return null
const types = field.typeIds.map(typeId => {
const dsTypes = dataSource.getTypes()
return dsTypes.find((type: Type) => type.id === typeId)
}).filter(Boolean)
const fields = types.map((type: Type | undefined) => type?.fields.find((f: Field) => f.label === key)).filter(Boolean)
switch (fields.length) {
case 0: return null
case 1: return fields[0]!
default: return {
id: `${field.id}.${key}`,
label: `${field.label}.${key}`,
typeIds: fields.reduce((typeIds, field) => typeIds
.concat(field!.typeIds.filter((t: string) => !typeIds.includes(t)))
, [] as string[]),
kind: 'object',
dataSourceId: field.dataSourceId
}
}
}
/**
* Generate a form to edit the options of a token
* @throws Error if the field has a token with an unknown type
*/
export function optionsFormKeySelector(editor: Editor, field: Field | null, options: Options, name: string): TemplateResult {
if (!field) return html`
<label>${name}
<input type="text" name=${name} />
</label>
`
const allDataSources = getAllDataSources()
const dataSource = allDataSources.find((ds: IDataSource) => ds.id === field.dataSourceId)
if (!dataSource?.isConnected()) {
return html`
<select name=${name}>
<option value="">Data source not connected</option>
</select>
`
}
const dsTypes = dataSource.getTypes()
const fieldOptions = field.typeIds
.flatMap(typeId => dsTypes.find((type: Type) => type.id === typeId)?.fields || [])
return html`
<select name=${name}>
<option value="">Select a ${name}</option>
${fieldOptions.map(f => html`<option value=${f.label} .selected=${f.label === options.key}>${f.label}</option>`)}
</select>
`
}
/**
* Get a container element from an option
* @throws Error if the option is not a string or an HTMLElement or a function
* @throws Error if the element is not found
*/
export function getElementFromOption(option: HTMLElement | string | (() => HTMLElement) | undefined, optionNameForError: string): HTMLElement {
// Get the container element for the UI
if (typeof option === 'undefined') {
// This should never happen as we set a default value in /index.ts
throw new Error('el option must be set')
} else if (typeof option === 'string') {
const el = document.querySelector(option)
if (!el) throw new Error(`Element ${option} not found`)
return el as HTMLElement
} else if (typeof option === 'function') {
const el = option()
if (!el) throw new Error('el option must be a returned by the provided function')
return el
} else if (option instanceof HTMLElement) {
return option
}
throw new Error(`${optionNameForError} must be a string or an HTMLElement or a function`)
}
export function getDefaultOptions(postFix = Math.random().toString(36).slice(2, 8)): GraphQLOptions {
return {
id: `ds-${postFix}`,
label: 'New data source',
type: 'graphql',
url: '',
method: 'POST',
headers: {},
readonly: false,
}
}
export function createDataSource(opts: Partial<GraphQLOptions> = {}, postFix?: string): IDataSource {
const options = {
...getDefaultOptions(postFix),
...opts,
}
switch (options.type) {
case 'graphql':
return new GraphQL(options)
default:
throw new Error(`Unknown data source type: ${options.type}`)
}
}