Skip to content
Merged
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
26 changes: 18 additions & 8 deletions examples/resources/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,14 @@ export function ExampleToolbarMenu() {
}

export function getHashQuery(): URLSearchParams | undefined {
const hash = window.location.hash;
if (hash.length > 1 && hash.includes('=')) {
// Parse the raw fragment from `href`: the `location.hash` getter returns it
// percent-decoded in Firefox, which corrupts values with encoded & or =
const href = window.location.href;
const hashIndex = href.indexOf('#');
const hash = hashIndex >= 0 ? href.substring(hashIndex + 1) : '';
if (hash.length > 0 && hash.includes('=')) {
try {
const hashQuery = new URLSearchParams(hash.substring(1));
const hashQuery = new URLSearchParams(hash);
return hashQuery;
} catch (e) {
/* ignore */
Expand All @@ -125,16 +129,22 @@ export function getHashQuery(): URLSearchParams | undefined {
return undefined;
}

export function setHashQueryParam(paramName: string, paramValue: string | null): void {
export function setHashQueryParams(params: { readonly [name: string]: string | null }): void {
const hashQuery = getHashQuery() ?? new URLSearchParams();
if (paramValue) {
hashQuery.set(paramName, paramValue);
} else {
hashQuery.delete(paramName);
for (const [paramName, paramValue] of Object.entries(params)) {
if (paramValue) {
hashQuery.set(paramName, paramValue);
} else {
hashQuery.delete(paramName);
}
}
window.location.hash = hashQuery.toString();
}

export function setHashQueryParam(paramName: string, paramValue: string | null): void {
setHashQueryParams({[paramName]: paramValue});
}

export function tryLoadLayoutFromLocalStorage(): Reactodia.SerializedDiagram | undefined {
let layoutKey: string | null = null;

Expand Down
104 changes: 0 additions & 104 deletions examples/resources/sparqlConnection.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion examples/sparql.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
</head>
<body>
<script type="module" src="./resources/pageControls.tsx"></script>
<script type="module" src="./sparql.tsx"></script>
<script type="module" src="./sparql/sparql.tsx"></script>
</body>
</html>
160 changes: 160 additions & 0 deletions examples/sparql/recentConnections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as React from 'react';
import * as Reactodia from '../../src/workspace';
import cx from 'clsx';

import styles from './sparql.module.css';

const RECENT_CONNECTIONS_KEY = 'reactodia-sparql-recent-connections';
const RECENT_CONNECTIONS_LIMIT = 8;

/**
* Connection settings without the password, as remembered in the recent
* connections list ({@link localStorage}, shared between browser tabs).
*
* An entry with a user-assigned {@link label} is pinned: it is never evicted
* from the list, so named configurations accumulate without limit while
* unnamed ones rotate through the most recent few.
*/
interface RecentConnection {
readonly endpointUrl: string;
readonly defaultGraphIris?: ReadonlyArray<string>;
readonly username?: string;
readonly label?: string;
}

export function RecentConnections(props: {
current?: RecentConnection;
onApply: (connection: RecentConnection) => void;
}) {
const {current, onApply} = props;
const [storedConnections, setStoredConnections] = React.useState(loadRecentConnections);

let recentConnections = storedConnections;
if (current && !recentConnections.find(other => equalRecentConnections(other, current))) {
recentConnections = [current, ...recentConnections];
}

if (recentConnections.length === 0) {
return null;
}

const nameRecentConnection = (index: number) => {
const connection = recentConnections[index];
const label = window.prompt(
'Name this connection (leave empty to unname it):',
connection.label ?? ''
);
if (label === null) {
return;
}
const renamed = recentConnections.map((other, i) => i === index
? {...other, label: label.trim() || undefined}
: other);
setStoredConnections(renamed);
storeRecentConnections(renamed);
};

const forgetRecentConnection = (index: number) => {
const remaining = recentConnections.filter((_, i) => i !== index);
setStoredConnections(remaining);
storeRecentConnections(remaining);
};

return (
<div className='reactodia-form__control-row'>
<label>Saved and recent connections</label>
{recentConnections.map((recent, index) => (
<div key={index} className={styles.recentConnection}>
<button type='button'
className={cx(
'reactodia-btn reactodia-btn-default',
styles.recentConnectionLabel
)}
title={[
recent.username
? 'Fill the connection form (the password will need to be re-entered)'
: 'Connect',
recent.endpointUrl,
...(recent.defaultGraphIris ?? []),
...(recent.username ? [`user: ${recent.username}`] : []),
].join('\n')}
onClick={() => onApply(recent)}>
{formatRecentConnection(recent)}
</button>
<button type='button'
className='reactodia-btn reactodia-btn-default'
title={'Name this connection to pin it permanently' +
(recent.label ? ` (currently: ${recent.label})` : '')}
onClick={() => nameRecentConnection(index)}>
✎
</button>
<button type='button'
className='reactodia-btn reactodia-btn-default'
title='Forget this connection'
onClick={() => forgetRecentConnection(index)}>
×
</button>
</div>
))}
</div>
);
}

function equalRecentConnections(a: RecentConnection, b: RecentConnection) {
return (
a.endpointUrl === b.endpointUrl &&
Reactodia.shallowArrayEqual(a.defaultGraphIris ?? [], b.defaultGraphIris ?? []) &&
(a.username ?? '') === (b.username ?? '')
);
}

function loadRecentConnections(): RecentConnection[] {
try {
const stored = localStorage.getItem(RECENT_CONNECTIONS_KEY);
const parsed = stored ? JSON.parse(stored) as RecentConnection[] : [];
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}

function storeRecentConnections(connections: ReadonlyArray<RecentConnection>): void {
try {
localStorage.setItem(RECENT_CONNECTIONS_KEY, JSON.stringify(connections));
} catch (e) {
/* ignore */
}
}

export function rememberRecentConnection(connection: RecentConnection): void {
const existing = loadRecentConnections();
const previous = existing.find(other => equalRecentConnections(connection, other));
// The limit applies to unnamed entries only; named ones are pinned
let unnamedCount = 0;
const connections: RecentConnection[] = [{...connection, label: previous?.label}];
for (const other of existing) {
if (equalRecentConnections(other, connection)) {
continue;
} else if (other.label || unnamedCount < RECENT_CONNECTIONS_LIMIT) {
connections.push(other);
if (!other.label) {
unnamedCount++;
}
}
}
storeRecentConnections(connections);
}

function formatRecentConnection(recent: RecentConnection): string {
if (recent.label) {
return recent.label;
}
const host = URL.canParse(recent.endpointUrl)
? new URL(recent.endpointUrl).host : recent.endpointUrl;
const graphCount = recent.defaultGraphIris?.length ?? 0;
return [
host,
graphCount > 0 ? `${graphCount} graph${graphCount === 1 ? '' : 's'}` : undefined,
recent.username,
].filter(Boolean).join(' · ');
}
12 changes: 12 additions & 0 deletions examples/sparql/sparql.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.recentConnection {
display: flex;
gap: 4px;
margin-bottom: 4px;
}

.recentConnectionLabel {
flex: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
25 changes: 8 additions & 17 deletions examples/sparql.tsx → examples/sparql/sparql.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import * as React from 'react';
import * as Reactodia from '../src/workspace';
import * as Reactodia from '../../src/workspace';

import {
ExampleToolbarMenu,
mountOnLoad,
tryLoadLayoutFromLocalStorage,
getHashQuery,
setHashQueryParam,
} from './resources/common';
} from '../resources/common';
import {
SparqlConnectionSettings, SparqlConnectionAction, showConnectionDialog,
} from './resources/sparqlConnection';
loadConnectionSettings, saveConnectionSettings, createConnectionOptions,
} from './sparqlConnection';

const Layouts = Reactodia.defineLayoutWorker(() => new Worker(
new URL('../src/layout.worker.ts', import.meta.url),
new URL('../../src/layout.worker.ts', import.meta.url),
{type: 'module'}
));

Expand All @@ -23,17 +22,9 @@ function SparqlExample() {
defaultLayout,
}));

const [connectionSettings, setConnectionSettings] = React.useState(
(): SparqlConnectionSettings | undefined => {
const params = getHashQuery();
const endpointUrl = params?.get('sparql-endpoint');
return endpointUrl ? {
endpointUrl,
} : undefined;
}
);
const [connectionSettings, setConnectionSettings] = React.useState(loadConnectionSettings);
const applyConnectionSettings = (settings: SparqlConnectionSettings) => {
setHashQueryParam('sparql-endpoint', settings.endpointUrl);
saveConnectionSettings(settings);
setConnectionSettings(settings);
};

Expand All @@ -43,7 +34,7 @@ function SparqlExample() {
if (connectionSettings) {
const diagram = tryLoadLayoutFromLocalStorage();
const dataProvider = new Reactodia.SparqlDataProvider({
endpointUrl: connectionSettings.endpointUrl,
...createConnectionOptions(connectionSettings),
imagePropertyUris: ['http://xmlns.com/foaf/0.1/img'],
}, Reactodia.OwlStatsSettings);

Expand Down
Loading
Loading