Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions apps/webapp/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import 'bootstrap-italia/dist/css/bootstrap-italia.min.css';
import '@teamdigitale/schema-editor/dist/style.css';
import { useState, useEffect } from 'react';
import { Editor } from './components/editor/editor';
import { Layout } from './components/layout/layout';
import { ConfigurationProvider } from './features/configuration';
import { CommandPalette } from './components/command-palette/command-palette';

function App() {
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
e.preventDefault();
setIsCommandPaletteOpen(true);
}
};

window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);

const handleSelectUrl = (url: string) => {
// Navigate to the selected schema URL
window.location.href = `?url=${encodeURIComponent(url)}`;
};

return (
<ConfigurationProvider>
<Layout>
<Editor />
</Layout>
<>
<Layout>
<Editor />
</Layout>
<CommandPalette
isOpen={isCommandPaletteOpen}
onClose={() => setIsCommandPaletteOpen(false)}
onSelectUrl={handleSelectUrl}
/>
</>
</ConfigurationProvider>
);
}
Expand Down
81 changes: 81 additions & 0 deletions apps/webapp/src/components/command-palette/command-palette.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
.command-palette-modal {
.modal-dialog {
margin-top: 10vh;
}

.modal-content {
max-height: 70vh;
overflow: hidden;
}
}

.command-palette-search {
margin-bottom: 1rem;

input {
font-size: 1.1rem;
padding: 0.75rem;
}
}

.command-palette-results {
max-height: 50vh;
overflow-y: auto;

ul {
margin: 0;
padding: 0;
}
}

.command-palette-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-radius: 4px;
margin-bottom: 0.5rem;
transition: background-color 0.2s;

&:hover,
&.selected {
background-color: #f0f6fc;
}

.schema-id {
font-weight: 600;
color: #0073e6;
margin-bottom: 0.25rem;
}

.schema-url {
font-size: 0.9rem;
color: #333;
word-break: break-all;
}

.schema-download-url {
font-size: 0.85rem;
color: #666;
margin-top: 0.25rem;
padding-left: 1rem;
word-break: break-all;
}
}

.no-results,
.loading-state {
padding: 2rem;
text-align: center;
color: #666;
}

.error-state {
padding: 2rem;
text-align: center;
color: #d32f2f;

.error-hint {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #666;
}
}
194 changes: 194 additions & 0 deletions apps/webapp/src/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { useState, useEffect, useRef } from 'react';
import { Modal, ModalHeader, ModalBody, Input } from 'design-react-kit';
import { useConfiguration } from '../../features/configuration';
import { executeSparqlQuery } from '../../utils/sparql';
import './command-palette.scss';

interface SchemaUrl {
id: string;
label: string;
url: string;
}

const SPARQL_QUERY = `
prefix admsapit: <https://w3id.org/italia/onto/ADMS/>
prefix COV: <https://w3id.org/italia/onto/COV/>
prefix dcat: <http://www.w3.org/ns/dcat#>
prefix dcatapit: <http://dati.gov.it/onto/dcatapit#>
prefix dct: <http://purl.org/dc/terms/>
prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>

select distinct ?label ?url where {
?s
a dcatapit:Distribution ;
dcat:downloadURL ?url ;
rdfs:label ?label
.
FILTER(
STRSTARTS(
STR(?s),
"https://w3id.org/italia/schemas/"
)
)
}
`;

interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onSelectUrl: (url: string) => void;
}

export function CommandPalette({ isOpen, onClose, onSelectUrl }: CommandPaletteProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [schemas, setSchemas] = useState<SchemaUrl[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { config } = useConfiguration();

// Fetch schemas from SPARQL when component mounts or when modal opens
useEffect(() => {
if (!isOpen) return;

const fetchSchemas = async () => {
setLoading(true);
setError(null);
try {
const sparqlUrl = config?.sparqlUrl || window.__ENV?.sparqlUrl;
if (!sparqlUrl) {
throw new Error('SPARQL endpoint not configured');
}

const response = await executeSparqlQuery(sparqlUrl, SPARQL_QUERY);

const schemaList: SchemaUrl[] = response.results.bindings.map((binding) => {
const label = binding.label.value;
const url = binding.url.value;

// Extract ID from URL (e.g., "categoria-pensione" from the URL)
const urlParts = url.split('/');
const id = urlParts[urlParts.length - 2] || urlParts[urlParts.length - 1];

return {
id,
label,
url
};
});

setSchemas(schemaList);
} catch (e) {
console.error('Failed to fetch schemas:', e);
setError(e instanceof Error ? e.message : 'Failed to load schemas');
} finally {
setLoading(false);
}
};

fetchSchemas();
}, [isOpen, config]);

const filteredUrls = schemas.filter((schema) =>
schema.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
schema.url.toLowerCase().includes(searchQuery.toLowerCase()) ||
schema.id.toLowerCase().includes(searchQuery.toLowerCase())
);

useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);

useEffect(() => {
setSelectedIndex(0);
}, [searchQuery]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filteredUrls.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filteredUrls[selectedIndex]) {
onSelectUrl(filteredUrls[selectedIndex].url);
handleClose();
}
} else if (e.key === 'Escape') {
e.preventDefault();
handleClose();
}
};

const handleClose = () => {
setSearchQuery('');
setSelectedIndex(0);
onClose();
};

const handleSelectUrl = (schema: SchemaUrl) => {
onSelectUrl(schema.url);
handleClose();
};

return (
<Modal
isOpen={isOpen}
toggle={handleClose}
className="command-palette-modal"
size="lg"
centered
>
<ModalHeader toggle={handleClose}>
Open Schema
</ModalHeader>
<ModalBody>
<div className="command-palette-search">
<Input
type="text"
placeholder="Search schemas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
innerRef={inputRef}
autoFocus
disabled={loading}
/>
</div>
<div className="command-palette-results">
{loading ? (
<div className="loading-state">Loading schemas...</div>
) : error ? (
<div className="error-state">
<p>Error: {error}</p>
<p className="error-hint">Please check the SPARQL endpoint configuration.</p>
</div>
) : filteredUrls.length > 0 ? (
<ul className="list-unstyled">
{filteredUrls.map((schema, index) => (
<li
key={schema.id}
className={`command-palette-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelectUrl(schema)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="schema-id">{schema.label}</div>
<div className="schema-url">{schema.url}</div>
</li>
))}
</ul>
) : (
<div className="no-results">
{schemas.length === 0 ? 'No schemas available' : 'No schemas found'}
</div>
)}
</div>
</ModalBody>
</Modal>
);
}
2 changes: 1 addition & 1 deletion apps/webapp/src/features/configuration/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ConfigurationContext } from './context';
import { Config } from './models';

export function ConfigurationProvider({ children }: { children: JSX.Element }) {
const [config, setConfig] = useState<Config>(window.__ENV);
const [config, setConfig] = useState<Config>(window.__ENV || {});

return <ConfigurationContext.Provider value={{ config, setConfig }}>{children}</ConfigurationContext.Provider>;
}
28 changes: 28 additions & 0 deletions apps/webapp/src/utils/sparql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
interface SparqlBinding {
type: string;
value: string;
}

interface SparqlResult {
[key: string]: SparqlBinding;
}

interface SparqlResponse {
head: {
vars: string[];
};
results: {
bindings: SparqlResult[];
};
}

export async function executeSparqlQuery(sparqlUrl: string, query: string): Promise<SparqlResponse> {
const endpoint = `${sparqlUrl.trim()}?format=json&query=${encodeURIComponent(query)}`;
const response = await fetch(endpoint, { cache: 'force-cache' });

if (!response.ok) {
throw new Error(`SPARQL query failed: ${response.statusText}`);
}

return await response.json();
}
Loading