diff --git a/.gitignore b/.gitignore index 496aeb54..76d0f6c9 100644 --- a/.gitignore +++ b/.gitignore @@ -130,5 +130,6 @@ jupytergis/_version.py python/jupytergis_lab/jupytergis_lab/labextension/ python/jupytergis_lab/jupytergis_lab/notebook/objects/_schema packages/base/rasterlayer_gallery/* +!packages/base/rasterlayer_gallery/custom_raster.png untitled* diff --git a/packages/base/rasterlayer_gallery/custom_raster.png b/packages/base/rasterlayer_gallery/custom_raster.png new file mode 100644 index 00000000..09ec6a71 Binary files /dev/null and b/packages/base/rasterlayer_gallery/custom_raster.png differ diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index 5176e8f5..c4d384a3 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -1,18 +1,12 @@ import { JupyterFrontEnd } from '@jupyterlab/application'; -import { Dialog, WidgetTracker } from '@jupyterlab/apputils'; +import { WidgetTracker } from '@jupyterlab/apputils'; import { ITranslator } from '@jupyterlab/translation'; import { redoIcon, undoIcon } from '@jupyterlab/ui-components'; import { - IDict, IJGISFormSchemaRegistry, - IJGISLayer, - IJGISLayerBrowserRegistry, - IJGISSource, - IJupyterGISModel + IJGISLayerBrowserRegistry } from '@jupytergis/schema'; -import { UUID } from '@lumino/coreutils'; -import { FormDialog } from './formdialog'; import { LayerBrowserWidget } from './layerBrowser/layerBrowserDialog'; import { JupyterGISWidget } from './widget'; @@ -65,17 +59,6 @@ export function addCommands( icon: undoIcon }); - commands.addCommand(CommandIDs.newRasterLayer, { - label: trans.__('New Tile Layer'), - isEnabled: () => { - return tracker.currentWidget - ? tracker.currentWidget.context.model.sharedModel.editable - : false; - }, - iconClass: 'fa fa-map', - execute: Private.createRasterSourceAndLayer(tracker) - }); - commands.addCommand(CommandIDs.openLayerBrowser, { label: trans.__('Open Layer Browser'), isEnabled: () => { @@ -84,7 +67,11 @@ export function addCommands( : false; }, iconClass: 'fa fa-book-open', - execute: Private.createLayerBrowser(tracker, layerBrowserRegistry) + execute: Private.createLayerBrowser( + tracker, + layerBrowserRegistry, + formSchemaRegistry + ) }); } @@ -95,7 +82,6 @@ export namespace CommandIDs { export const redo = 'jupytergis:redo'; export const undo = 'jupytergis:undo'; - export const newRasterLayer = 'jupytergis:newRasterLayer'; export const openLayerBrowser = 'jupytergis:openLayerBrowser'; } @@ -121,7 +107,8 @@ namespace Private { export function createLayerBrowser( tracker: WidgetTracker, - layerBrowserRegistry: IJGISLayerBrowserRegistry + layerBrowserRegistry: IJGISLayerBrowserRegistry, + formSchemaRegistry: IJGISFormSchemaRegistry ) { return async () => { const current = tracker.currentWidget; @@ -130,83 +117,10 @@ namespace Private { return; } - const dialog = new Dialog({ - body: new LayerBrowserWidget( - current.context.model, - layerBrowserRegistry.getRegistryLayers() - ), - buttons: [Dialog.cancelButton(), Dialog.okButton()] - }); - await dialog.launch(); - }; - } - - // TODO Allow for creating only a source (e.g. loading a CSV file) - // TODO Allow for creating only a layer (e.g. creating a vector layer given a source selected from a dropdown) - export function createRasterSourceAndLayer( - tracker: WidgetTracker - ) { - return async (args: any) => { - const current = tracker.currentWidget; - - if (!current) { - return; - } - - const form = { - title: 'Raster Layer parameters', - default: (model: IJupyterGISModel) => { - return { - name: 'RasterSource', - url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - maxZoom: 24, - minZoom: 0 - }; - } - }; - - current.context.model.syncFormData(form); - - const dialog = new FormDialog({ - context: current.context, - title: form.title, - sourceData: form.default(current.context.model), - schema: FORM_SCHEMA['RasterSource'], - syncData: (props: IDict) => { - const sharedModel = current.context.model.sharedModel; - if (!sharedModel) { - return; - } - - const { name, ...parameters } = props; - - const sourceId = UUID.uuid4(); - - const sourceModel: IJGISSource = { - type: 'RasterSource', - name, - parameters: { - url: parameters.url, - minZoom: parameters.minZoom, - maxZoom: parameters.maxZoom - } - }; - - const layerModel: IJGISLayer = { - type: 'RasterLayer', - parameters: { - source: sourceId - }, - visible: true, - name: name + ' Layer' - }; - - sharedModel.addSource(sourceId, sourceModel); - current.context.model.addLayer(UUID.uuid4(), layerModel); - }, - cancelButton: () => { - current.context.model.syncFormData(undefined); - } + const dialog = new LayerBrowserWidget({ + model: current.context.model, + registry: layerBrowserRegistry.getRegistryLayers(), + formSchemaRegistry }); await dialog.launch(); }; diff --git a/packages/base/src/svg.d.ts b/packages/base/src/declaration.d.ts similarity index 50% rename from packages/base/src/svg.d.ts rename to packages/base/src/declaration.d.ts index 16cdda94..fe84e07e 100644 --- a/packages/base/src/svg.d.ts +++ b/packages/base/src/declaration.d.ts @@ -2,3 +2,8 @@ declare module "*.svg" { const value: string; // @ts-ignore export default value; } + +declare module '*.png'{ + const value: string; // @ts-ignore + export default value; +} diff --git a/packages/base/src/formdialog.tsx b/packages/base/src/formdialog.tsx index d235fab9..f77f240a 100644 --- a/packages/base/src/formdialog.tsx +++ b/packages/base/src/formdialog.tsx @@ -9,33 +9,24 @@ export interface IFormDialogOptions { schema: IDict; sourceData: IDict; title: string; - cancelButton: (() => void) | boolean; syncData: (props: IDict) => void; context: DocumentRegistry.IContext; } +// TODO This is currently not used, shall we remove it or will we need it later? export class FormDialog extends Dialog { constructor(options: IFormDialogOptions) { - let cancelCallback: (() => void) | undefined = undefined; - if (options.cancelButton) { - cancelCallback = () => { - if (options.cancelButton !== true && options.cancelButton !== false) { - options.cancelButton(); - } - this.resolve(0); - }; - } const filePath = options.context.path; const jGISModel = options.context.model; const body = (
); diff --git a/packages/base/src/layerBrowser/layerBrowserDialog.tsx b/packages/base/src/layerBrowser/layerBrowserDialog.tsx index 326e7cd4..1870bf89 100644 --- a/packages/base/src/layerBrowser/layerBrowserDialog.tsx +++ b/packages/base/src/layerBrowser/layerBrowserDialog.tsx @@ -1,33 +1,40 @@ -import { - faCheck, - faMagnifyingGlass, - faPlus -} from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faPlus, faClose } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { + IDict, + IJGISFormSchemaRegistry, IJGISLayer, IJGISLayerDocChange, IJGISSource, IJupyterGISModel, IRasterLayerGalleryEntry } from '@jupytergis/schema'; -import { ReactWidget } from '@jupyterlab/ui-components'; import { UUID } from '@lumino/coreutils'; import React, { ChangeEvent, MouseEvent, useEffect, useState } from 'react'; +import { RasterSourcePropertiesForm } from '../panelview'; +import { deepCopy } from '../tools'; +import { Dialog } from '@jupyterlab/apputils'; + +import CUSTOM_RASTER_IMAGE from '../../rasterlayer_gallery/custom_raster.png'; interface ILayerBrowserDialogProps { model: IJupyterGISModel; registry: IRasterLayerGalleryEntry[]; + formSchemaRegistry: IJGISFormSchemaRegistry; + cancel: () => void; } export const LayerBrowserComponent = ({ model, - registry + registry, + formSchemaRegistry, + cancel }: ILayerBrowserDialogProps) => { const [searchTerm, setSearchTerm] = useState(''); const [activeLayers, setActiveLayers] = useState([]); const [selectedCategory, setSelectedCategory] = useState(); + const [creatingCustomRaster, setCreatingCustomRaster] = useState(false); const [galleryWithCategory, setGalleryWithCategory] = useState(registry); @@ -39,12 +46,6 @@ export const LayerBrowserComponent = ({ ); useEffect(() => { - // Override default dialog style - const dialog = document.getElementsByClassName('jp-Dialog-content'); - const dialogHeader = document.getElementsByClassName('jp-Dialog-header'); - dialogHeader[0].setAttribute('style', 'padding: 0'); - dialog[0].classList.add('jgis-dialog-override'); - model.sharedModel.layersChanged.connect(handleLayerChange); return () => { @@ -72,8 +73,8 @@ export const LayerBrowserComponent = ({ const categoryTab = event.target as HTMLElement; const sameAsOld = categoryTab.innerText === selectedCategory?.innerText; - categoryTab.classList.toggle('jgis-layer-browser-category-selected'); - selectedCategory?.classList.remove('jgis-layer-browser-category-selected'); + categoryTab.classList.toggle('jGIS-layer-browser-category-selected'); + selectedCategory?.classList.remove('jGIS-layer-browser-category-selected'); const filteredGallery = sameAsOld ? registry @@ -86,6 +87,10 @@ export const LayerBrowserComponent = ({ setSelectedCategory(sameAsOld ? null : categoryTab); }; + const handleCustomTileClick = () => { + setCreatingCustomRaster(true); + }; + /** * Add tile layer and source to model * @param tile Tile to add @@ -112,30 +117,92 @@ export const LayerBrowserComponent = ({ model.addLayer(UUID.uuid4(), layerModel); }; + if (creatingCustomRaster) { + const schema = deepCopy( + formSchemaRegistry.getSchemas().get('RasterSource') + ); + if (!schema) { + return; + } + + // Inject name in schema + schema['required'] = ['name', ...schema['required']]; + schema['properties'] = { + name: { type: 'string', description: 'The name of the raster layer' }, + ...schema['properties'] + }; + + const syncData = (props: IDict) => { + const sharedModel = model.sharedModel; + if (!sharedModel) { + return; + } + + const { name, ...parameters } = props; + + const sourceId = UUID.uuid4(); + + const sourceModel: IJGISSource = { + type: 'RasterSource', + name, + parameters + }; + + const layerModel: IJGISLayer = { + type: 'RasterLayer', + parameters: { + source: sourceId + }, + visible: true, + name: name + ' Layer' + }; + + sharedModel.addSource(sourceId, sourceModel); + model.addLayer(UUID.uuid4(), layerModel); + }; + + return ( +
+ +
+ ); + } + return ( -
-
-
-

Layer Browser

-
+
+
+
+

Layer Browser

+
-
+
-
+
{providers.map(provider => ( {provider} @@ -143,36 +210,57 @@ export const LayerBrowserComponent = ({ ))}
-
+
+
handleCustomTileClick()} + > +
+ +
+ +
+
+
+
+

+ Custom Raster Layer +

+
+

+ Create A Custom Raster Layer +

+
+
{filteredGallery.map(tile => (
handleTileClick(tile)} > -
- +
+ {activeLayers.indexOf(tile.name) === -1 ? ( -
+
) : ( -
+
-

Added!

+

Added!

)}
-
-
-

+
+
+

{tile.name}

- {/*

+ {/*

{tile.description} placeholder

*/}
-

+

{tile.source.attribution}

@@ -183,20 +271,33 @@ export const LayerBrowserComponent = ({ ); }; -export class LayerBrowserWidget extends ReactWidget { - private _model: IJupyterGISModel; - private _registry: IRasterLayerGalleryEntry[]; +export interface ILayerBrowserOptions { + model: IJupyterGISModel; + registry: IRasterLayerGalleryEntry[]; + formSchemaRegistry: IJGISFormSchemaRegistry; +} + +export class LayerBrowserWidget extends Dialog { + constructor(options: ILayerBrowserOptions) { + let cancelCallback: (() => void) | undefined = undefined; + cancelCallback = () => { + this.resolve(0); + }; + + const body = ( + + ); + + super({ body, buttons: [Dialog.cancelButton(), Dialog.okButton()] }); - constructor(model: IJupyterGISModel, registry: IRasterLayerGalleryEntry[]) { - super(); this.id = 'jupytergis::layerBrowser'; - this._model = model; - this._registry = registry; - } - render() { - return ( - - ); + // Override default dialog style + this.addClass('jGIS-layerbrowser-FormDialog'); } } diff --git a/packages/base/src/panelview/formbuilder.tsx b/packages/base/src/panelview/formbuilder.tsx index 6cd7c2d1..3c9a0a0b 100644 --- a/packages/base/src/panelview/formbuilder.tsx +++ b/packages/base/src/panelview/formbuilder.tsx @@ -9,16 +9,44 @@ import { IJupyterGISModel } from '@jupytergis/schema'; import { deepCopy } from '../tools'; interface IStates { - internalData?: IDict; schema?: IDict; } interface IProps { + /** + * The context of the form, whether it's for creating an object or updating its properties. This will have the effect of showing or not inputs for readonly properties. + */ + formContext: 'update' | 'create'; + + /** + * The source data for filling the form + */ sourceData: IDict | undefined; + + /** + * Path to the file + */ filePath?: string; + + /** + * Current GIS model + */ model: IJupyterGISModel; + + /** + * callback for syncing back the data into the model upon form submit + * @param properties + */ syncData: (properties: IDict) => void; + + /** + * The schema for the rjsf form + */ schema?: IDict; + + /** + * Cancel callback, no cancel button will be displayed if not defined + */ cancel?: () => void; } @@ -54,18 +82,17 @@ export const LuminoSchemaForm = ( export class ObjectPropertiesForm extends React.Component { constructor(props: IProps) { super(props); - const sourceData = { ...this.props.sourceData }; + this.currentFormData = deepCopy(this.props.sourceData); this.state = { - internalData: sourceData, schema: props.schema }; } componentDidUpdate(prevProps: IProps, prevState: IStates): void { if (prevProps.sourceData !== this.props.sourceData) { - const sourceData = deepCopy(this.props.sourceData); + this.currentFormData = deepCopy(this.props.sourceData); const schema = deepCopy(this.props.schema); - this.setState(old => ({ ...old, internalData: sourceData, schema })); + this.setState(old => ({ ...old, schema })); } } @@ -97,9 +124,15 @@ export class ObjectPropertiesForm extends React.Component { this.processSchema(data, v, uiSchema[k]); } - // Don't show readOnly properties when coming from the properties panel + // Don't show readOnly properties when it's a form for updating an object if (v['readOnly']) { - this.removeFormEntry(k, data, schema, uiSchema); + if (this.props.formContext === 'create') { + delete v['readOnly']; + } + + if (this.props.formContext === 'update') { + this.removeFormEntry(k, data, schema, uiSchema); + } } }); } @@ -127,12 +160,16 @@ export class ObjectPropertiesForm extends React.Component { } } - protected syncData(properties: IDict) { + protected syncData(properties: IDict | undefined) { + if (!properties) { + return; + } + this.props.syncData(properties); } protected onFormChange(e: IChangeEvent) { - // This is a no-op here + this.currentFormData = e.formData; } protected onFormBlur(id: string, value: any) { @@ -140,24 +177,17 @@ export class ObjectPropertiesForm extends React.Component { } private onFormSubmit = (e: ISubmitEvent): void => { - const internalData = { ...this.state.internalData }; - Object.entries(e.formData).forEach(([k, v]) => (internalData[k] = v)); - this.setState( - old => ({ - ...old, - internalData - }), - () => { - this.syncData(e.formData); - this.props.cancel && this.props.cancel(); - } - ); + this.currentFormData = e.formData; + + this.syncData(this.currentFormData); + + this.props.cancel && this.props.cancel(); }; render(): React.ReactNode { if (this.props.schema) { const schema = { ...this.state.schema, additionalProperties: true }; - const formData = this.state.internalData; + const formData = this.currentFormData; const uiSchema = { additionalProperties: { @@ -202,13 +232,15 @@ export class ObjectPropertiesForm extends React.Component { className="jp-Dialog-button jp-mod-accept jp-mod-styled" onClick={() => submitRef.current?.click()} > -
Submit
+
Ok

); } } + + protected currentFormData: IDict | undefined; } interface ILayerProps extends IProps { @@ -303,15 +335,14 @@ export class RasterSourcePropertiesForm extends ObjectPropertiesForm { } protected onFormBlur(id: string, value: any) { + super.onFormBlur(id, value); + // Is there a better way to spot the url text entry? if (!id.endsWith('_url')) { return; } - const internalData = this.state.internalData; - if (internalData) { - internalData.url = value; - } - this.setState({ internalData }); + + // Force a rerender on url change, as it probably changes the schema this.forceUpdate(); } } diff --git a/packages/base/src/panelview/objectproperties.tsx b/packages/base/src/panelview/objectproperties.tsx index 3a57fc01..f79afd67 100644 --- a/packages/base/src/panelview/objectproperties.tsx +++ b/packages/base/src/panelview/objectproperties.tsx @@ -207,6 +207,7 @@ class ObjectPropertiesReact extends React.Component {

Source Properties

{

Layer Properties

{

Source Properties

{selectedObjSource.type === 'RasterSource' && ( { const layerBrowser = await openLayerBrowser(page); const gridTiles = layerBrowser.locator( - '.jgis-layer-browser-container .jgis-layer-browser-grid .jgis-layer-browser-tile' + '.jGIS-layer-browser-container .jGIS-layer-browser-grid .jGIS-layer-browser-tile' ); return gridTiles; @@ -127,7 +127,7 @@ test.describe('#layerBrowser', () => { const layerBrowser = await openLayerBrowser(page); const gridTiles = layerBrowser.locator( - '.jgis-layer-browser-container .jgis-layer-browser-grid .jgis-layer-browser-tile' + '.jGIS-layer-browser-container .jGIS-layer-browser-grid .jGIS-layer-browser-tile' ); const numberOfTiles = await gridTiles.count(); @@ -138,13 +138,13 @@ test.describe('#layerBrowser', () => { const gridTiles = await getGridTiles(page); await page.getByPlaceholder('Search...').click(); await page.getByPlaceholder('Search...').fill('mapnik'); - await expect(gridTiles).toHaveCount(1); + await expect(gridTiles).toHaveCount(2); }); test('category filters should work', async ({ page }) => { const gridTiles = await getGridTiles(page); await page.getByText('Strava', { exact: true }).click(); - await expect(gridTiles).toHaveCount(5); + await expect(gridTiles).toHaveCount(6); }); test('clicking category filter twice should clear filter', async ({