|
24 | 24 |
|
25 | 25 | */ |
26 | 26 |
|
27 | | -import React, {useEffect, useState} from 'react' |
| 27 | +import React, {use, useEffect, useState} from 'react' |
28 | 28 | import {useDebounce} from 'use-debounce' |
| 29 | +import lookmlParser from 'lookml-parser' |
29 | 30 |
|
30 | 31 | import Button from '@mui/material/Button' |
31 | 32 | import Stack from '@mui/material/Stack' |
32 | 33 | 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' |
33 | 43 | import Typography from '@mui/material/Typography' |
34 | 44 |
|
35 | 45 | const ProjectPage = (props) => { |
36 | 46 | const { |
37 | | - project, |
| 47 | + initProjectFiles, |
38 | 48 | setTab, |
39 | 49 | setProject |
40 | 50 | } = props |
41 | 51 |
|
42 | 52 | // 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: ''}) |
44 | 57 |
|
45 | 58 | // Derived state |
46 | | - const [debouncedProjectText] = useDebounce(projectText,1000) |
47 | | - const [ctaDisabled, setCtaDisabled] = useState(true) |
| 59 | + const [debouncedSelectedFileContent] = useDebounce(selectedFileContent,1000) |
48 | 60 | const [projectStatus, setProjectStatus] = useState("") |
| 61 | + const [ctaDisabled, setCtaDisabled] = useState(true) |
49 | 62 |
|
50 | 63 | // Effects |
51 | | - useEffect(parseProject,[debouncedProjectText]) |
| 64 | + useEffect(updateSelectedFileContent, [selectedFileIndex, projectFiles]) |
| 65 | + useEffect(updateProjectFile, [debouncedSelectedFileContent]) |
| 66 | + useEffect(() => { |
| 67 | + parseProject() |
| 68 | + }, [projectFiles]) |
52 | 69 |
|
53 | 70 | 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> |
74 | 152 | </Stack> |
75 | 153 | </Stack> |
76 | 154 | ) |
77 | 155 |
|
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") |
82 | 245 | setProject(undefined) |
83 | 246 | setCtaDisabled(true) |
84 | 247 | 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) |
88 | 256 | setProjectStatus("✅ Project ready") |
89 | | - setProject(project) |
90 | 257 | setCtaDisabled(false) |
91 | | - } |
92 | | - catch(e){ |
93 | | - setProjectStatus(`❌ Invalid JSON. ${trunc(e,120)}`) |
| 258 | + } catch (e) { |
| 259 | + setProjectStatus(`❌ Invalid LookML. ${trunc(e,120)}`) |
94 | 260 | setProject(undefined) |
95 | 261 | setCtaDisabled(true) |
96 | | - } |
97 | 262 | } |
98 | 263 | } |
| 264 | +} |
99 | 265 |
|
100 | 266 | function trunc(message, maxLength){ |
101 | 267 | message = message.toString() |
|
0 commit comments