Skip to content

Commit 2a14f9e

Browse files
committed
UI changes partially complete. WIP adding LookML parser
1 parent 5045ff2 commit 2a14f9e

File tree

8 files changed

+52153
-64920
lines changed

8 files changed

+52153
-64920
lines changed

docs/tools/rule-sandbox/rule-sandbox-web-prod.js

Lines changed: 51737 additions & 64691 deletions
Large diffs are not rendered by default.

npm-shrinkwrap.json

Lines changed: 7 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"fromentries": "^1.3.2",
4040
"jsonpath-plus": "^10.2.0",
4141
"liyad": "^0.2.4",
42-
"lookml-parser": "^7.0.1",
42+
"lookml-parser": "^7.1.0",
4343
"minimist": "^1.2.6",
4444
"require-from-string": "^2.0.2"
4545
},

tools/rule-sandbox/components/project-page.jsx

Lines changed: 204 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,78 +24,244 @@
2424
2525
*/
2626

27-
import React, {useEffect, useState} from 'react'
27+
import React, {use, useEffect, useState} from 'react'
2828
import {useDebounce} from 'use-debounce'
29+
import lookmlParser from 'lookml-parser'
2930

3031
import Button from '@mui/material/Button'
3132
import Stack from '@mui/material/Stack'
3233
import TextField from '@mui/material/TextField'
34+
import Box from '@mui/material/Box'
35+
import List from '@mui/material/List'
36+
import ListItem from '@mui/material/ListItem'
37+
import ListItemButton from '@mui/material/ListItemButton'
38+
import ListItemText from '@mui/material/ListItemText'
39+
import IconButton from '@mui/material/IconButton'
40+
import EditIcon from '@mui/icons-material/Edit'
41+
import DeleteIcon from '@mui/icons-material/Delete'
42+
import AddIcon from '@mui/icons-material/Add'
3343
import Typography from '@mui/material/Typography'
3444

3545
const ProjectPage = (props) => {
3646
const {
37-
project,
47+
initProjectFiles,
3848
setTab,
3949
setProject
4050
} = props
4151

4252
// Core state
43-
const [projectText, setProjectText] = useState(project ? JSON.stringify(project, undefined, 4) : "")
53+
const [projectFiles, setProjectFiles] = useState(initProjectFiles ?? [])
54+
const [selectedFileContent, setSelectedFileContent] = useState("")
55+
const [selectedFileIndex, setSelectedFileIndex] = useState(projectFiles.length ? 0 : undefined)
56+
const [renaming, setRenaming] = useState({index: null, isNew: false, path: ''})
4457

4558
// Derived state
46-
const [debouncedProjectText] = useDebounce(projectText,1000)
47-
const [ctaDisabled, setCtaDisabled] = useState(true)
59+
const [debouncedSelectedFileContent] = useDebounce(selectedFileContent,1000)
4860
const [projectStatus, setProjectStatus] = useState("")
61+
const [ctaDisabled, setCtaDisabled] = useState(true)
4962

5063
// Effects
51-
useEffect(parseProject,[debouncedProjectText])
64+
useEffect(updateSelectedFileContent, [selectedFileIndex, projectFiles])
65+
useEffect(updateProjectFile, [debouncedSelectedFileContent])
66+
useEffect(() => {
67+
parseProject()
68+
}, [projectFiles])
5269

5370
return (
54-
<Stack direction="column" spacing={4} className="project-page">
55-
<TextField
56-
id='project-text-field'
57-
label='Project JSON'
58-
placeholder='JSON representation of LookML project contents, as per output of node-lookml-parser (file-output=array)'
59-
multiline
60-
maxRows={24}
61-
style={{width:"100%", minWidth:"20em"}}
62-
value={projectText}
63-
onChange={changeProjectText}
64-
></TextField>
65-
<Stack direction="row" justifyContent="flex-end" alignItems="center" spacing={4}>
66-
<Typography>{projectStatus}</Typography>
67-
<Button
68-
variant="contained"
69-
onClick={()=>setTab("rule")}
70-
value="rule"
71-
disabled={ctaDisabled}>
72-
Inspect Rule(s)
73-
</Button>
71+
<Stack direction="column" spacing={2} className="project-page">
72+
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={4}>
73+
<Typography variant="h6">LookML Project Files</Typography>
74+
<Stack direction="row" justifyContent="flex-end" alignItems="center" spacing={2}>
75+
<Typography>{projectStatus}</Typography>
76+
<Button
77+
variant="contained"
78+
onClick={()=>setTab("rule")}
79+
value="rule"
80+
disabled={ctaDisabled}>
81+
Inspect Rule(s)
82+
</Button>
83+
</Stack>
84+
</Stack>
85+
<Stack direction="row" spacing={2} >
86+
<Box sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper', border: '1px solid #ddd' }}>
87+
<Stack direction="row" justifyContent="flex-end" alignItems="center" sx={{borderBottom:'1px solid #ddd', padding: '2px'}}>
88+
<IconButton aria-label="new file" onClick={handleNewFile}><AddIcon /></IconButton>
89+
</Stack>
90+
<List sx={{maxHeight: 400, overflowY: 'auto'}}>
91+
{projectFiles.map((file, f) => (
92+
<ListItem
93+
key={f}
94+
disablePadding
95+
secondaryAction={renaming.index !== f && (
96+
<Stack direction="row">
97+
<IconButton edge="end" aria-label="rename" onClick={() => handleRenameFile(f)}>
98+
<EditIcon />
99+
</IconButton>
100+
<IconButton edge="end" aria-label="delete" onClick={() => handleDeleteFile(f)}>
101+
<DeleteIcon />
102+
</IconButton>
103+
</Stack>
104+
)}
105+
>
106+
{renaming.index === f ? (
107+
<TextField
108+
value={renaming.path}
109+
onChange={handleRenameInputChange}
110+
onBlur={handleRenameConfirm}
111+
onKeyDown={(e) => {
112+
if (e.key === 'Enter') handleRenameConfirm();
113+
if (e.key === 'Escape') handleRenameCancel();
114+
}}
115+
size="small"
116+
sx={{ margin: '4px 16px', width: 'calc(100% - 32px)' }}
117+
autoFocus
118+
/>
119+
) : (
120+
<ListItemButton selected={f === selectedFileIndex} onClick={() => handleFileSelect(f)}>
121+
<ListItemText
122+
primary={file.path}
123+
primaryTypographyProps={{
124+
style: {
125+
whiteSpace: 'nowrap',
126+
overflow: 'hidden',
127+
textOverflow: 'ellipsis'
128+
}}}
129+
/>
130+
</ListItemButton>
131+
)}
132+
</ListItem>
133+
))}
134+
{renaming.isNew && renaming.index === projectFiles.length && (
135+
<ListItem key="renaming-new-file" disablePadding>
136+
{/* This space is intentionally left blank for the new file text field to appear */}
137+
</ListItem>
138+
)}
139+
</List>
140+
</Box>
141+
<TextField
142+
id='project-text-field'
143+
label={selectedFileIndex !== undefined ? `Editing: ${projectFiles[selectedFileIndex]?.path}`: "Select a file to edit"}
144+
placeholder='LookML content of the file'
145+
multiline
146+
rows={16}
147+
style={{width:"100%"}}
148+
value={selectedFileContent}
149+
onChange={handleFileContentsChange}
150+
disabled={selectedFileIndex === undefined}
151+
></TextField>
74152
</Stack>
75153
</Stack>
76154
)
77155

78-
function changeProjectText(event){setProjectText(event.target.value)}
79-
function parseProject(){
80-
if(projectText === ""){
81-
setProjectStatus("Provide Project JSON")
156+
function handleFileSelect(index) {
157+
setSelectedFileIndex(index)
158+
}
159+
160+
function handleFileContentsChange(event) {
161+
setSelectedFileContent(event.target.value)
162+
}
163+
164+
function handleRenameInputChange(event) {
165+
setRenaming(r => ({...r, path: event.target.value}))
166+
}
167+
168+
function handleRenameConfirm() {
169+
const { index, path, isNew } = renaming;
170+
if (!path) {
171+
handleRenameCancel();
172+
return;
173+
}
174+
// Check if path (other than the original) already exists
175+
if (projectFiles.some((file, i) => file.path === path && i !== index)) {
176+
alert("File path already exists or is invalid.");
177+
return;
178+
}
179+
180+
const newFiles = [...projectFiles];
181+
newFiles[index] = { ...newFiles[index], path: path };
182+
setProjectFiles(newFiles);
183+
if (isNew) {
184+
setSelectedFileIndex(index);
185+
}
186+
setRenaming({index: null, isNew: false, path: ''});
187+
}
188+
189+
function handleRenameCancel() {
190+
if (renaming.isNew) {
191+
setProjectFiles(files => files.slice(0, -1));
192+
}
193+
setRenaming({index: null, isNew: false, path: ''});
194+
}
195+
196+
function handleNewFile() {
197+
// Add a temporary placeholder file and enter renaming mode for it
198+
const newIndex = projectFiles.length;
199+
setProjectFiles([...projectFiles, {path: '', contents: ''}]);
200+
setRenaming({index: newIndex, isNew: true, path: 'new_file.view.lkml'});
201+
}
202+
203+
function handleRenameFile(index) {
204+
setRenaming({index: index, isNew: false, path: projectFiles[index].path});
205+
}
206+
207+
function handleDeleteFile(index) {
208+
// If we are deleting the file currently being edited, cancel the edit first.
209+
if (renaming.index === index) {
210+
handleRenameCancel();
211+
}
212+
213+
if (window.confirm(`Are you sure you want to delete ${projectFiles[index].path}?`)) {
214+
const newFiles = projectFiles.filter((_, i) => i !== index);
215+
setProjectFiles(newFiles);
216+
if (selectedFileIndex === index) {
217+
setSelectedFileIndex(newFiles.length > 0 ? 0 : undefined);
218+
} else if (selectedFileIndex > index) {
219+
setSelectedFileIndex(i => i - 1);
220+
}
221+
}
222+
}
223+
224+
function updateSelectedFileContent() {
225+
if (selectedFileIndex !== undefined && projectFiles[selectedFileIndex]) {
226+
setSelectedFileContent(projectFiles[selectedFileIndex].contents);
227+
} else {
228+
setSelectedFileContent("");
229+
}
230+
}
231+
232+
function updateProjectFile() {
233+
if (selectedFileIndex === undefined) return;
234+
const currentFile = projectFiles[selectedFileIndex];
235+
if (!currentFile || currentFile.contents === debouncedSelectedFileContent) return;
236+
237+
const newFiles = [...projectFiles];
238+
newFiles[selectedFileIndex] = { ...currentFile, contents: debouncedSelectedFileContent };
239+
setProjectFiles(newFiles);
240+
}
241+
242+
async function parseProject() {
243+
if (!projectFiles || projectFiles.length === 0) {
244+
setProjectStatus("No files in project")
82245
setProject(undefined)
83246
setCtaDisabled(true)
84247
return
85-
}
86-
try{
87-
const project = JSON.parse(projectText)
248+
}
249+
try {
250+
setProjectStatus("Parsing LookML...")
251+
const parsedProject = await lookmlParser.parseFiles({source: projectFiles})
252+
// CONTINUE HERE ^ Need to go update LookML parser to work better in browser environments
253+
// - Make sure that references to glob are conditional/isolated.
254+
// - Don't rely on fs/path to load PEG code, use import/require insteat
255+
setProject(parsedProject)
88256
setProjectStatus("✅ Project ready")
89-
setProject(project)
90257
setCtaDisabled(false)
91-
}
92-
catch(e){
93-
setProjectStatus(`❌ Invalid JSON. ${trunc(e,120)}`)
258+
} catch (e) {
259+
setProjectStatus(`❌ Invalid LookML. ${trunc(e,120)}`)
94260
setProject(undefined)
95261
setCtaDisabled(true)
96-
}
97262
}
98263
}
264+
}
99265

100266
function trunc(message, maxLength){
101267
message = message.toString()

tools/rule-sandbox/config/webpack/webpack-common.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,9 @@ module.exports = {
8080
// }),
8181
// ],
8282
// stats: statsConfig,
83+
resolve: {
84+
alias: {
85+
"glob": false,
86+
}
87+
},
8388
}

tools/rule-sandbox/index.jsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@
2525
*/
2626

2727
import React from 'react'
28-
import ReactDOM from 'react-dom'
28+
import { createRoot } from 'react-dom/client'
2929

3030
import App from './components/app.jsx'
3131

3232
document.addEventListener('DOMContentLoaded',()=>{
33-
const root = document.createElement('div')
34-
root.setAttribute("id","react-app-root")
35-
document.body.appendChild(root)
33+
const container = document.createElement('div')
34+
container.setAttribute("id","react-app-root")
35+
document.body.appendChild(container)
3636

37-
ReactDOM.render(<App />, root)
37+
const root = createRoot(container)
38+
root.render(<App />)
3839
})

0 commit comments

Comments
 (0)