From 8f5118a0f82b2b242c474ff56000be928fa9686e Mon Sep 17 00:00:00 2001 From: Andy Damevin Date: Thu, 16 Nov 2023 15:17:26 +0100 Subject: [PATCH] qs web-components --- .gitignore | 1 + pom.xml | 27 +++ .../io/quarkus/search/app/SearchService.java | 3 +- .../search/app/SearchWebComponent.java | 32 +++ .../io/quarkus/search/app/dto/SearchHit.java | 3 +- .../InputProviderHtmlBodyTextBridge.java | 2 +- src/main/resources/templates/pub/index.html | 201 ++++++++++++++++++ src/main/resources/web/app/qs-form.ts | 174 +++++++++++++++ src/main/resources/web/app/qs-guide.ts | 36 ++++ src/main/resources/web/app/qs-target.ts | 128 +++++++++++ src/main/resources/web/app/temp-override.css | 3 + src/main/resources/web/tsconfig.json | 13 ++ .../quarkus/search/app/WebComponentTest.java | 36 ++++ 13 files changed, 656 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/quarkus/search/app/SearchWebComponent.java create mode 100644 src/main/resources/templates/pub/index.html create mode 100644 src/main/resources/web/app/qs-form.ts create mode 100644 src/main/resources/web/app/qs-guide.ts create mode 100644 src/main/resources/web/app/qs-target.ts create mode 100644 src/main/resources/web/app/temp-override.css create mode 100644 src/main/resources/web/tsconfig.json create mode 100644 src/test/java/io/quarkus/search/app/WebComponentTest.java diff --git a/.gitignore b/.gitignore index 8c7863e7..51d2fcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ pom.xml.releaseBackup pom.xml.versionsBackup release.properties .flattened-pom.xml +node_modules/ # Eclipse .project diff --git a/pom.xml b/pom.xml index ebf0ea5c..0cacc7d4 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,26 @@ io.quarkus quarkus-resteasy-reactive + + io.quarkiverse.web-bundler + quarkus-web-bundler + 1.1.4 + + + org.mvnpm + lit + 2.7.6 + + + org.mvnpm + lodash + 4.17.21 + + + io.quarkiverse.qute.web + quarkus-qute-web + 3.0.0.CR2 + io.quarkus quarkus-smallrye-openapi @@ -133,6 +153,13 @@ org.assertj assertj-core + test + + + io.quarkiverse.playwright + quarkus-playwright + 0.0.1 + test org.awaitility diff --git a/src/main/java/io/quarkus/search/app/SearchService.java b/src/main/java/io/quarkus/search/app/SearchService.java index 243790f9..9fd6a923 100644 --- a/src/main/java/io/quarkus/search/app/SearchService.java +++ b/src/main/java/io/quarkus/search/app/SearchService.java @@ -23,7 +23,7 @@ import io.quarkus.search.app.entity.Guide; @ApplicationScoped -@Path("/") +@Path("/api") public class SearchService { private static final Integer PAGE_SIZE = 50; @@ -32,6 +32,7 @@ public class SearchService { SearchSession session; @GET + @Path("search") @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Search for any resource") @Transactional diff --git a/src/main/java/io/quarkus/search/app/SearchWebComponent.java b/src/main/java/io/quarkus/search/app/SearchWebComponent.java new file mode 100644 index 00000000..cb85c7ef --- /dev/null +++ b/src/main/java/io/quarkus/search/app/SearchWebComponent.java @@ -0,0 +1,32 @@ +package io.quarkus.search.app; + +import io.quarkiverse.web.bundler.runtime.Bundle; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.reactive.Cache; + +import java.net.URI; +import java.net.URISyntaxException; + +@Path("/") +public class SearchWebComponent { + + @Inject + Bundle bundle; + + // This route allows to access the web-component js on a fixed path + // without affecting caching of the script + @Path("/quarkus-search.js") + @Cache(noCache = true) + @GET + public Response script() { + try { + return Response.seeOther(new URI(bundle.script("main"))).build(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/io/quarkus/search/app/dto/SearchHit.java b/src/main/java/io/quarkus/search/app/dto/SearchHit.java index f321ae20..a4fa4869 100644 --- a/src/main/java/io/quarkus/search/app/dto/SearchHit.java +++ b/src/main/java/io/quarkus/search/app/dto/SearchHit.java @@ -5,5 +5,6 @@ import org.hibernate.search.mapper.pojo.mapping.definition.annotation.ProjectionConstructor; @ProjectionConstructor -public record SearchHit(@IdProjection String id, @FieldProjection String title) { +public record SearchHit(@IdProjection String id, @FieldProjection String title, @FieldProjection String summary, + @FieldProjection String keywords) { } diff --git a/src/main/java/io/quarkus/search/app/hibernate/InputProviderHtmlBodyTextBridge.java b/src/main/java/io/quarkus/search/app/hibernate/InputProviderHtmlBodyTextBridge.java index 31f34882..b98b791a 100644 --- a/src/main/java/io/quarkus/search/app/hibernate/InputProviderHtmlBodyTextBridge.java +++ b/src/main/java/io/quarkus/search/app/hibernate/InputProviderHtmlBodyTextBridge.java @@ -18,7 +18,7 @@ public String toIndexedValue(InputProvider provider, ValueBridgeToIndexedValueCo try (var in = provider.open()) { Element body = Jsoup.parse(in, StandardCharsets.UTF_8.name(), "/").body(); // Content div has two grid columns: actual content and TOC. There's not much use of the TOC, we want the content only: - Element content = body.selectFirst(".content .grid__item"); + Element content = body.selectFirst(".content .grid_item"); if (content != null) { // Means we've found a guide content column. hence let's use that to have only real content: return content.text(); diff --git a/src/main/resources/templates/pub/index.html b/src/main/resources/templates/pub/index.html new file mode 100644 index 00000000..997af931 --- /dev/null +++ b/src/main/resources/templates/pub/index.html @@ -0,0 +1,201 @@ + + + + Quarkus Search Web-Component test + + + + + +{#bundle tag="style"/} + + + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+

Guides - Latest

+ + +
+
+
+

Tutorials

+

Short and focused exercises to get you going quickly.

+
+
+
+

Building a Native Executable

+

Build native executables with GraalVM or Mandrel.

+
+
+
getting-started, native
+
+ + +
+

Collect metrics using Micrometer

+

Create an application that uses the Micrometer metrics library to collect runtime, + extension and application metrics and expose them as a Prometheus (OpenMetrics) endpoint.

+
+
+
observability
+
+ + +
+

Creating Your First Application

+

Discover how to create your first Quarkus application.

+
+
+
getting-started
+
+ + +
+

Creating a tutorial

+

Create a new tutorial that guides users through creating, running, and testing a Quarkus + application that uses annotations from an imaginary extension.

+
+
+
contributing
+
+ + +
+

Getting Started With Reactive

+

Learn more about developing reactive applications with Quarkus.

+
+
+
getting-started
+
+ + +
+

Protect Quarkus web application by using an Auth0 OpenID Connect + provider

+

Quarkus Security provides comprehensive OpenId Connect (OIDC) and OAuth2 support with + its quarkus-oidc extension, supporting both Authorization code flow and Bearer token authentication + mechanisms.

+
+
oidc, sso, auth0
+
security, web
+
+ + +
+

Protect a service application by using + OpenID Connect (OIDC) Bearer token authentication

+

Use the Quarkus OpenID Connect (OIDC) extension to secure a Jakarta REST application + with Bearer token authentication.

+
+
+
security
+
+ + +
+

Protect a web application by using OpenID + Connect (OIDC) authorization code flow

+

With the Quarkus OpenID Connect (OIDC) extension, you can protect application HTTP + endpoints by using the OIDC Authorization Code Flow mechanism.

+
+
+
security, web
+
+ + +
+

Quarkus Tools in your favorite IDE

+

Learn more about Quarkus integrations in IDEs.

+
+
+
getting-started
+
+ + +
+

Secure a Quarkus application with Basic authentication + and Jakarta Persistence

+

Secure your Quarkus application endpoints by combining the built-in Quarkus Basic + authentication with the Jakarta Persistence identity provider to enable role-based access control (RBAC).

+
+
+
getting-started, security
+
+ + +
+

Using our Tooling

+

Explore the Quarkus developer toolchain which makes Quarkus development so fast and + enjoyable.

+
+
+
getting-started
+
+ + +
+

Your second Quarkus application

+

Discover some of the features that make developing with Quarkus a joyful experience.

+
+
+
core, data, getting-started
+
+ + +
+
+ +
+ + \ No newline at end of file diff --git a/src/main/resources/web/app/qs-form.ts b/src/main/resources/web/app/qs-form.ts new file mode 100644 index 00000000..5080efb7 --- /dev/null +++ b/src/main/resources/web/app/qs-form.ts @@ -0,0 +1,174 @@ +import {LitElement, html, css, nothing, render} from 'lit'; +import {customElement, state, property} from 'lit/decorators.js'; +import debounce from 'lodash/debounce'; + +export const QS_START_EVENT = 'qs-start'; +export const QS_END_EVENT = 'qs-end'; +export const QS_RESULT_EVENT = 'qs-result'; +export const QS_RESULT_NEXT_EVENT = 'qs-result-next'; + +export const QS_NEXT_PAGE_EVENT = 'qs-next-page'; + +export interface QsResult { + hits: QsHit[]; + hasMoreHits: boolean; +} + +export interface QsHit { + title: string; + summary: string; + url: string; + keywords: string | undefined; + content: string | undefined; + type: string | undefined; +} + + +@customElement('qs-form') +export class QsForm extends LitElement { + + static styles = css` + .quarkus-search { + display: block !important; + } + + .d-none { + display: none; + } + `; + + @property({type: String}) api: string = "/api/search"; + @property({type: String}) minChars: number = 3; + @property({type: String}) quarkusVersion: string; + + private _page: number = 0; + private _total: number = 0; + private _loading: boolean = false; + + render() { + return html` +
+ +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + const formElements = this._getFormElements(); + this.addEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + formElements.forEach((el) => { + const eventName = this._isInput(el) ? 'input' : 'change'; + el.addEventListener(eventName, this._handleInputChange); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener(QS_NEXT_PAGE_EVENT, this._handleNextPage); + const formElements = this._getFormElements(); + formElements.forEach(el => { + const eventName = this._isInput(el) ? 'input' : 'change'; + el.removeEventListener(eventName, this._handleInputChange); + }); + } + + private _getFormElements(): NodeListOf { + return this.querySelectorAll('input, select'); + } + + private _readQueryInputs() { + const formElements = this._getFormElements(); + const formData = {}; + + formElements.forEach((el: HTMLFormElement) => { + if (el.value && el.value.length > 0) { + formData[el.name] = el.value; + } + }); + + console.log(JSON.stringify(formData)); + return formData; + } + + private _search = () => { + if (this._loading) { + return; + } + this.dispatchEvent(new CustomEvent(QS_START_EVENT)); + const data = this._readQueryInputs(); + this._loading = true; + this._jsonFetch(new AbortController(), 'GET', data, 1000) + .then((r: any) => { + let event = QS_RESULT_EVENT + if (this._page > 0 && r.hits.length > 0) { + this._total += r.hits.length; + event = QS_RESULT_NEXT_EVENT; + } else { + this._total = r.hits.length; + } + const hasMoreHits = r.total > this._total; + + this.dispatchEvent(new CustomEvent(event, {detail: {...r, hasMoreHits}})); + }).finally(() => { + this._loading = false; + this.dispatchEvent(new CustomEvent(QS_END_EVENT)); + }); + } + + private _searchDebounced = debounce(this._search, 300); + + private _handleInputChange = (e: Event) => { + const target = e.target as HTMLFormElement; + console.log(target.value); + if (this._isInput(target)) { + if (target.value.length === 0) { + this._clearSearch(); + return; + } + if (target.value.length < this.minChars) { + return; + } + } + this._searchDebounced(); + } + + private _handleNextPage = (e: CustomEvent) => { + this._page++; + this._search(); + } + + private _isInput(el: HTMLFormElement) { + return el.tagName.toLowerCase() === 'input' + } + + private async _jsonFetch(controller: AbortController, method: string, params: object, timeout: number) { + const queryParams: Record = { + ...params, + 'page': this._page.toString() + }; + const timeoutId = setTimeout(() => controller.abort(), timeout) + const response = await fetch(this.api + '?' + (new URLSearchParams(queryParams)).toString(), { + method: method, + mode: 'cors', + headers: { + 'Access-Control-Allow-Origin':'*' + }, + signal: controller.signal, + body: null + }) + clearTimeout(timeoutId) + if (response.ok) { + return await response.json() + } else { + throw 'Response status is ' + response.status + '; response: ' + await response.text() + } + } + + + private _clearSearch() { + this._page = 0; + this._total = 0; + this.dispatchEvent(new CustomEvent(QS_RESULT_EVENT, {detail: {}})); + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/qs-guide.ts b/src/main/resources/web/app/qs-guide.ts new file mode 100644 index 00000000..569611a8 --- /dev/null +++ b/src/main/resources/web/app/qs-guide.ts @@ -0,0 +1,36 @@ +// @ts-ignore +import { LitElement, html } from 'lit'; +// @ts-ignore +import { customElement, property } from 'lit/decorators.js'; + + +/** + * This component shows the live server processes + */ +@customElement('qs-guide') +export class QsGuide extends LitElement { + + @property({type: Object}) data: any; + + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + render() { + return html` +
+

${this.data.title}

+
+

${this.data.summary}

+
+
${this.data.keywords}
+ ${this.data.content ? html`
${this.data.content}
`: ''} +
+ `; + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/qs-target.ts b/src/main/resources/web/app/qs-target.ts new file mode 100644 index 00000000..5401633a --- /dev/null +++ b/src/main/resources/web/app/qs-target.ts @@ -0,0 +1,128 @@ +import {html, LitElement} from 'lit'; +import {customElement, property, state, queryAll} from 'lit/decorators.js'; +import './qs-guide' +import {QS_END_EVENT, QS_NEXT_PAGE_EVENT, QS_RESULT_EVENT, QS_RESULT_NEXT_EVENT, QS_START_EVENT, QsResult} from "./qs-form"; +import debounce from 'lodash/debounce'; + + +/** + * This component shows the live server processes + */ +@customElement('qs-target') +export class QsTarget extends LitElement { + + @property({type: String}) private type: string; + @state() private _result: QsResult | undefined; + @state() private _loading = false; + @queryAll('.qs-hit') private _hits: NodeListOf; + + private _form: HTMLElement; + + connectedCallback() { + super.connectedCallback(); + this._form = document.querySelector("qs-form"); + this._form.addEventListener(QS_RESULT_EVENT, this._handleResult); + this._form.addEventListener(QS_RESULT_NEXT_EVENT, this._handleNextResult); + this._form.addEventListener(QS_START_EVENT, this._loadingStart); + this._form.addEventListener(QS_END_EVENT, this._loadingEnd); + document.addEventListener('scroll', this._handleScrollDebounced) + } + + disconnectedCallback() { + this._form.removeEventListener(QS_RESULT_EVENT, this._handleResult); + this._form.removeEventListener(QS_RESULT_NEXT_EVENT, this._handleNextResult); + this._form.removeEventListener(QS_START_EVENT, this._loadingStart); + this._form.removeEventListener(QS_END_EVENT, this._loadingEnd); + document.removeEventListener('scroll', this._handleScrollDebounced); + super.disconnectedCallback(); + } + + render() { + if (this._result?.hits) { + if (this._result.hits.length === 0) { + return html` +
+

No results found

+
+ `; + } + const result = this._result.hits.map(i => this._renderHit(i)); + return html` +
+ ${result} +
+ ${this._loading ? this._renderLoading() : ''} + `; + } + if (this._loading) { + return html`
${this._renderLoading()}
`; + } + return html` +
+ +
+ `; + } + + + private _renderLoading() { + return html` +

Loading...

+ `; + } + + private _renderHit(i) { + switch (this.type) { + case 'guide': + return html` + ` + } + return '' + } + + + private _handleScroll = (e) => { + + if (!this._result?.hasMoreHits) { + // No more hits to fetch. + console.log("no more hits"); + return + } + const lastHit = this._hits.length == 0 ? null : this._hits[this._hits.length - 1] + if (!lastHit) { + // No result card is being displayed at the moment. + return + } + const scrollElement = document.documentElement // Scroll bar is on the element + const bottomOfViewport = scrollElement.scrollTop + scrollElement.clientHeight + const topOfLastResultCard = lastHit.offsetTop + if (bottomOfViewport >= topOfLastResultCard) { + // We have scrolled to the bottom of the last result card. + this._form.dispatchEvent(new CustomEvent(QS_NEXT_PAGE_EVENT)) + } + } + private _handleScrollDebounced = debounce(this._handleScroll, 100); + + private _handleResult = (e: CustomEvent) => { + console.debug("Received _result in qs-target: ", e.detail); + this._result = e.detail; + } + + private _handleNextResult = (e: CustomEvent) => { + console.debug("Received next _result in qs-target: ", e.detail); + if (!this._result) { + this._result = e.detail; + return; + } + this._result.hits = this._result.hits.concat(e.detail.hits); + this._result.hasMoreHits = e.detail.hasMoreHits; + } + + private _loadingStart = () => { + this._loading = true; + } + + private _loadingEnd = () => { + this._loading = false; + } +} \ No newline at end of file diff --git a/src/main/resources/web/app/temp-override.css b/src/main/resources/web/app/temp-override.css new file mode 100644 index 00000000..b64eea2c --- /dev/null +++ b/src/main/resources/web/app/temp-override.css @@ -0,0 +1,3 @@ +qs-form::slotted(*) { + display: none; +} \ No newline at end of file diff --git a/src/main/resources/web/tsconfig.json b/src/main/resources/web/tsconfig.json new file mode 100644 index 00000000..fde19d9a --- /dev/null +++ b/src/main/resources/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "target": "es2017", + "lib": ["es2021", "DOM"], + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitAny": false, + "skipLibCheck": true + } +} \ No newline at end of file diff --git a/src/test/java/io/quarkus/search/app/WebComponentTest.java b/src/test/java/io/quarkus/search/app/WebComponentTest.java new file mode 100644 index 00000000..11046cd7 --- /dev/null +++ b/src/test/java/io/quarkus/search/app/WebComponentTest.java @@ -0,0 +1,36 @@ +package io.quarkus.search.app; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.ElementHandle; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Response; +import io.quarkiverse.playwright.InjectPlaywright; +import io.quarkiverse.playwright.WithPlaywright; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URL; + +@QuarkusTest +@WithPlaywright // <1> +public class WebComponentTest { + @InjectPlaywright + BrowserContext context; + + @TestHTTPResource("/") + URL testPage; + + @Test + public void testIndex() { + final Page page = context.newPage(); + Response response = page.navigate(testPage.toString()); + Assertions.assertEquals("OK", response.statusText()); + + page.waitForLoadState(); + + String title = page.title(); + Assertions.assertEquals("Quarkus Search Web-Component test", title); + } +}