267 lines
6.0 KiB
TypeScript
267 lines
6.0 KiB
TypeScript
import Engine from 'publicodes'
|
|
import { Documentation, getDocumentationSiteMap } from 'publicodes-react'
|
|
import { invertObj, last } from 'ramda'
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import MonacoEditor from 'react-monaco-editor'
|
|
import { useHistory, useLocation } from 'react-router-dom'
|
|
import styled from 'styled-components'
|
|
|
|
const EXAMPLE_CODE = `
|
|
# Bienvenue dans le bac à sable du langage publicodes !
|
|
# Pour en savoir plus sur le langage :
|
|
# => https://publi.codes/documentation/principes-de-base
|
|
|
|
prix:
|
|
prix . carottes: 2€/kg
|
|
prix . champignons: 5€/kg
|
|
prix . avocat: 2€/avocat
|
|
|
|
dépenses primeur:
|
|
formule:
|
|
somme:
|
|
- prix . carottes * 1.5 kg
|
|
- prix . champignons * 500g
|
|
- prix . avocat * 3 avocat
|
|
`
|
|
|
|
export default function Studio() {
|
|
const { search, pathname } = useLocation()
|
|
const initialValue = useMemo(() => {
|
|
const code = new URLSearchParams(search ?? '').get('code')
|
|
return code ? code : EXAMPLE_CODE
|
|
}, [search])
|
|
const [editorValue, setEditorValue] = useState(initialValue)
|
|
const debouncedEditorValue = useDebounce(editorValue, 1000)
|
|
|
|
const history = useHistory()
|
|
useEffect(() => {
|
|
history.replace({
|
|
pathname,
|
|
search: `?code=${encodeURIComponent(debouncedEditorValue)}`,
|
|
})
|
|
}, [debouncedEditorValue, history])
|
|
|
|
const handleShare = useCallback(() => {
|
|
navigator.clipboard.writeText(window.location.href)
|
|
}, [window.location.href])
|
|
|
|
return (
|
|
<Layout className="ui__ full-width">
|
|
<MonacoEditor
|
|
language="yaml"
|
|
height="90vh"
|
|
defaultValue={editorValue}
|
|
onChange={(newValue) => setEditorValue(newValue ?? '')}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
}}
|
|
/>
|
|
<section
|
|
css={`
|
|
padding: 0 1rem;
|
|
flex: 1;
|
|
`}
|
|
>
|
|
<ErrorBoundary key={debouncedEditorValue}>
|
|
{/* TODO: prévoir de changer la signature de EngineProvider */}
|
|
|
|
<Results rules={debouncedEditorValue} onClickShare={handleShare} />
|
|
</ErrorBoundary>
|
|
</section>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
type ResultsProps = {
|
|
rules: string
|
|
onClickShare: React.MouseEventHandler
|
|
}
|
|
|
|
class Logger {
|
|
messages: string[] = []
|
|
warn(message: string) {
|
|
this.messages.push(message)
|
|
}
|
|
error(message: string) {
|
|
this.messages.push(message)
|
|
}
|
|
log(message: string) {
|
|
this.messages.push(message)
|
|
}
|
|
toJSX() {
|
|
return this.messages.map((m) => (
|
|
<div
|
|
css={`
|
|
background: lightyellow;
|
|
padding: 20px;
|
|
border-radius: 5px;
|
|
`}
|
|
key={m}
|
|
>
|
|
{nl2br(m)}
|
|
</div>
|
|
))
|
|
}
|
|
}
|
|
|
|
export const Results = ({ onClickShare, rules }: ResultsProps) => {
|
|
const logger = useMemo(() => new Logger(), [rules])
|
|
const engine = useMemo(() => new Engine(rules, { logger }), [rules, logger])
|
|
const targets = useMemo(() => Object.keys(engine.getParsedRules()), [engine])
|
|
const documentationPath = '/studio'
|
|
const pathToRules = useMemo(
|
|
() => getDocumentationSiteMap({ engine, documentationPath }),
|
|
[engine, documentationPath]
|
|
)
|
|
const ruleToPaths = useMemo(() => invertObj(pathToRules), [pathToRules])
|
|
const { search, pathname } = useLocation()
|
|
const history = useHistory()
|
|
const setCurrentTarget = useCallback(
|
|
(target) =>
|
|
history.replace({
|
|
pathname: ruleToPaths[target],
|
|
search,
|
|
}),
|
|
[ruleToPaths, history, search]
|
|
)
|
|
useEffect(() => {
|
|
if (!pathToRules[pathname]) {
|
|
setCurrentTarget(last(targets))
|
|
}
|
|
})
|
|
|
|
return (
|
|
<>
|
|
{logger.toJSX()}
|
|
<div
|
|
css={`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
`}
|
|
>
|
|
<small>
|
|
Aller à{' '}
|
|
<select
|
|
onChange={(e) => {
|
|
setCurrentTarget(e.target.value)
|
|
}}
|
|
css={`
|
|
font-size: inherit;
|
|
color: inherit;
|
|
font-family: inherit;
|
|
`}
|
|
>
|
|
{targets.map((target) => (
|
|
<option
|
|
key={target}
|
|
value={target}
|
|
selected={pathToRules[pathname] === target}
|
|
>
|
|
{target}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</small>
|
|
<div className="ui__ answer-group">
|
|
<button className="ui__ simple small button" onClick={onClickShare}>
|
|
🔗 Copier le lien
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ErrorBoundary>
|
|
<Documentation
|
|
engine={engine}
|
|
documentationPath={documentationPath}
|
|
language="fr"
|
|
/>
|
|
</ErrorBoundary>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const newlineRegex = /(\r\n|\r|\n)/g
|
|
|
|
function nl2br(str: string) {
|
|
if (typeof str !== 'string') {
|
|
return str
|
|
}
|
|
|
|
return str.split(newlineRegex).map(function (line, index) {
|
|
if (line.match(newlineRegex)) {
|
|
return React.createElement('br', { key: index })
|
|
}
|
|
return line
|
|
})
|
|
}
|
|
|
|
const Layout = styled.div`
|
|
flex-grow: 1;
|
|
display: flex;
|
|
height: 100%;
|
|
> :first-child {
|
|
width: 55% !important;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
flex-direction: column;
|
|
padding: 20px;
|
|
|
|
> :first-child {
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
`
|
|
|
|
class ErrorBoundary extends React.Component {
|
|
state: { error: false | { message: string; name: string } } = { error: false }
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
console.error(error)
|
|
return { error: { message: error.message, name: error.name } }
|
|
}
|
|
render() {
|
|
if (this.state.error) {
|
|
return (
|
|
<div
|
|
css={`
|
|
background: lightyellow;
|
|
padding: 20px;
|
|
border-radius: 5px;
|
|
`}
|
|
>
|
|
<strong>{this.state.error.name}</strong>
|
|
<br />
|
|
{nl2br(this.state.error.message)}
|
|
<br />
|
|
<br />
|
|
<a onClick={() => window.location.reload()}>Rafraichir</a>
|
|
</div>
|
|
)
|
|
}
|
|
return this.props.children
|
|
}
|
|
}
|
|
|
|
function useDebounce<T>(value: T, delay: number) {
|
|
const [debouncedValue, setDebouncedValue] = useState(value)
|
|
useEffect(
|
|
() => {
|
|
// Update debounced value after delay
|
|
const handler = setTimeout(() => {
|
|
setDebouncedValue(value)
|
|
}, delay)
|
|
|
|
// Cancel the timeout if value changes (also on delay change or unmount)
|
|
// This is how we prevent debounced value from updating if value is changed ...
|
|
// .. within the delay period. Timeout gets cleared and restarted.
|
|
return () => {
|
|
clearTimeout(handler)
|
|
}
|
|
},
|
|
[value, delay] // Only re-call effect if value or delay changes
|
|
)
|
|
return debouncedValue
|
|
}
|