diff --git a/.gitignore b/.gitignore index 8c7863e7..389473bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ pom.xml.versionsBackup release.properties .flattened-pom.xml +# web-bundler uses it for IDE support +node_modules/ + # Eclipse .project .classpath diff --git a/pom.xml b/pom.xml index 26e54181..cc084253 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,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 @@ -179,6 +199,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/WebComponents.java b/src/main/java/io/quarkus/search/app/WebComponents.java new file mode 100644 index 00000000..4990835c --- /dev/null +++ b/src/main/java/io/quarkus/search/app/WebComponents.java @@ -0,0 +1,35 @@ +package io.quarkus.search.app; + +import io.quarkiverse.web.bundler.runtime.Bundle; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import org.jboss.resteasy.reactive.Cache; + +import java.net.URI; +import java.net.URISyntaxException; + +@Path("/web-components") +public class WebComponents { + + @Inject + Bundle bundle; + + @Context + UriInfo uriInfo; + + // This route allows to access the web-component js on a fixed path + // without affecting caching of the script + @Path("/search.js") + @Cache(noCache = true) + @GET + public Response script() { + URI baseUri = uriInfo.getBaseUri(); + URI redirectUri = baseUri.resolve(bundle.script("main")); + return Response.temporaryRedirect(redirectUri).build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1451905b..b4bde5bf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -161,4 +161,9 @@ quarkus.openshift.ports."management".container-port=9000 quarkus.openshift.ports."management".host-port=90 # Don't use the version in (service) selectors, # otherwise a rollback to an earlier version (due to failing startup) makes the service unavailable -quarkus.openshift.add-version-to-label-selectors=false \ No newline at end of file +quarkus.openshift.add-version-to-label-selectors=false + + +# Web Bundler configuration +quarkus.web-bundler.loaders.data-url=svg +quarkus.web-bundler.loaders.file= \ No newline at end of file diff --git a/src/main/resources/templates/pub/index.html b/src/main/resources/templates/pub/index.html new file mode 100644 index 00000000..03d14461 --- /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/assets/icons/docsicon-concepts.svg b/src/main/resources/web/app/assets/icons/docsicon-concepts.svg new file mode 100644 index 00000000..4916aa33 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-concepts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-guides.svg b/src/main/resources/web/app/assets/icons/docsicon-guides.svg new file mode 100644 index 00000000..d7ca4e0d --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-guides.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-pdf.svg b/src/main/resources/web/app/assets/icons/docsicon-pdf.svg new file mode 100644 index 00000000..bba3719a --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-pdf.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/src/main/resources/web/app/assets/icons/docsicon-reference.svg b/src/main/resources/web/app/assets/icons/docsicon-reference.svg new file mode 100644 index 00000000..ecf8b094 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-reference.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg b/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg new file mode 100644 index 00000000..4a012259 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/docsicon-tutorials.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/index.ts b/src/main/resources/web/app/assets/icons/index.ts new file mode 100644 index 00000000..800ca155 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/index.ts @@ -0,0 +1,21 @@ +import tutorials from './docsicon-tutorials.svg'; +import guides from './docsicon-guides.svg'; +import pdf from './docsicon-pdf.svg'; +import concepts from './docsicon-concepts.svg'; +import reference from './docsicon-reference.svg'; +import loading from './loading.svg'; + +const docs = { + tutorials, + guides, + pdf, + concepts, + reference +}; + +const icons = { + docs, + loading +} + +export default icons; \ No newline at end of file diff --git a/src/main/resources/web/app/assets/icons/loading.svg b/src/main/resources/web/app/assets/icons/loading.svg new file mode 100644 index 00000000..fd7db769 --- /dev/null +++ b/src/main/resources/web/app/assets/icons/loading.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + \ 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..38f924cf --- /dev/null +++ b/src/main/resources/web/app/qs-form.ts @@ -0,0 +1,173 @@ +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_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; +} + +/** + * This component is the form that triggers the search + */ +@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/guides/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) { + this._total += r.hits.length; + } else { + this._total = r.hits.length; + } + const hasMoreHits = r.hits.length > 0 && r.total > this._total; + + this.dispatchEvent(new CustomEvent(event, {detail: {...r, page: this._page, 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..094d22c0 --- /dev/null +++ b/src/main/resources/web/app/qs-guide.ts @@ -0,0 +1,122 @@ +import { LitElement, html, css, unsafeCSS } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { customElement, property } from 'lit/decorators.js'; +import icons from './assets/icons'; + +/** + * This component is a single guide hit in the search results + */ +@customElement('qs-guide') +export class QsGuide extends LitElement { + + static styles = css` + :host { + --link-color: #1259A5; + --link-hover-color: #c00; + } + + @media screen and (max-width: 1300px) { + + .qs-hit { + grid-column: span 6; + } + } + + .highlighted { + font-weight: bold; + } + + .qs-guide { + background-size: 70px 70px; + background-repeat: no-repeat; + background-image: url('${unsafeCSS(icons.docs.guides)}'); + + &.type-tutorial { + background-image: url('${unsafeCSS(icons.docs.tutorials)}'); + } + + &.type-guide { + background-image: url('${unsafeCSS(icons.docs.guides)}'); + } + + &.type-reference { + background-image: url('${unsafeCSS(icons.docs.reference)}'); + } + + &.type-pdf { + background-image: url('${unsafeCSS(icons.docs.pdf)}'); + } + + &.type-concepts { + background-image: url('${unsafeCSS(icons.docs.concepts)}'); + } + } + + .qs-guide a { + line-height: 1.5rem; + font-weight: 400; + cursor: pointer; + text-decoration: underline; + color: var(--link-color); + } + + .qs-guide a:hover { + color: var(--link-hover-color); + } + + .qs-guide h4 { + margin: 1rem 0 0 90px; + } + + .qs-guide div { + margin: 1rem 0 0 90px; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 400; + } + + .qs-guide .content-highlights { + font-size: 0.7rem; + } + + .summary { + min-height: 40px; + } + `; + + @property({type: Object}) data: any; + + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + render() { + return html` +
+

${this._renderContent(this.data.title)}

+
+

${this._renderContent(this.data.summary)}

+
+
${this._renderContent(this.data.keywords)}
+
+ ${this._renderContent(this.data.content)} +
+
+ `; + } + + private _renderContent(content: string) { + if(!content) { + return content; + } + if(Array.isArray(content)) { + return content.map((c) => html`

${this._renderContent(c)}

`); + } + return unsafeHTML(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..6bbf9677 --- /dev/null +++ b/src/main/resources/web/app/qs-target.ts @@ -0,0 +1,177 @@ +import {html, LitElement, css, unsafeCSS} 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_START_EVENT, QsResult} from "./qs-form"; +import debounce from 'lodash/debounce'; +import icons from "./assets/icons"; + + +/** + * This component is the target of the search results + */ +@customElement('qs-target') +export class QsTarget extends LitElement { + + static styles = css` + + .loading { + background-image: url('${unsafeCSS(icons.loading)}'); + background-repeat: no-repeat; + background-position: top; + background-size: 45px; + padding-top: 55px; + text-align: center; + padding-bottom: 10px; + } + + .qs-hits { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-gap: 1em; + clear: both; + } + + .no-hits { + padding: 10px; + margin: 10px; + font-size: 1.2rem; + line-height: 1.5; + font-weight: 400; + font-style: italic; + text-align: center; + background: var(--empty-background-color, #F0CA4D); + } + + + qs-guide { + grid-column: span 4; + margin: 1rem 0rem 1rem 0rem; + + @media screen and (max-width: 1300px) { + grid-column: span 6; + } + + @media screen and (max-width: 768px) { + grid-column: span 12; + margin: 1rem 0rem 1rem 0rem; + } + + @media screen and (max-width: 480px) { + grid-column: span 12; + } + } + `; + + @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_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_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` +
+

Sorry, no ${this.type} matched your search. Please try again.

+
+ `; + } + 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` +
Searching...
+ `; + } + + private _renderHit(i) { + switch (this.type) { + case 'guide': + return html` + ` + } + return '' + } + + + private _handleScroll = (e) => { + if (this._loading) { + return; + } + 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._loading = true; + this._form.dispatchEvent(new CustomEvent(QS_NEXT_PAGE_EVENT)); + } + } + private _handleScrollDebounced = debounce(this._handleScroll, 100); + + private _handleResult = (e: CustomEvent) => { + console.debug("Received results in qs-target: ", e.detail); + if (!this._result || !e.detail.hits || e.detail.page === 0) { + this._result = e.detail; + return; + } + this._result.hits = this._result.hits.concat(e.detail.hits); + console.debug(`${this._result.hits.length} results in qs-target: `); + 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..c9d64592 --- /dev/null +++ b/src/main/resources/web/app/temp-override.css @@ -0,0 +1,4 @@ +qs-form::slotted(*) { + display: none; +} + diff --git a/src/main/resources/web/index.d.ts b/src/main/resources/web/index.d.ts new file mode 100644 index 00000000..c26f543d --- /dev/null +++ b/src/main/resources/web/index.d.ts @@ -0,0 +1 @@ +declare module '*.svg'; \ 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..7ea57d06 --- /dev/null +++ b/src/main/resources/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "target": "es2016", + "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); + } +}