Compare commits
17
Commits
release/0.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1512030281
|
||
|
|
f0e5625469
|
||
|
|
17c45dd6bc
|
||
|
|
d166ed20b0
|
||
|
|
d6e94e334e
|
||
|
|
2520a043d7
|
||
|
|
0f5cab8c73 | ||
|
|
3e34c50e39 | ||
|
|
f1a903ed65 | ||
|
|
829b6f64ba | ||
|
|
ee2ac85345 | ||
|
|
9ee71ffd23 | ||
|
|
ecc16412a6 | ||
|
|
552534a9e6
|
||
|
|
c8d8baff06
|
||
|
|
2e5fd2b59f
|
||
|
|
d4547a0f90
|
No files matched your search
@@ -11,4 +11,4 @@ workflows:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
ignore: /.*/
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
+6
-5
@@ -6,10 +6,11 @@ browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
release_tag_policy: none
|
||||
test_branch_policy: all
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
snapshot: false
|
||||
release_candidate: false
|
||||
release: false
|
||||
asciidoc: false
|
||||
release_completion: false
|
||||
@@ -1,23 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Author-only upstream-changed, retired, unresolved-key and per-bundle coverage tables; conditional plain-text Current/Upstream record comparison.
|
||||
|
||||
- Help app over core's visible `helpContent` table, with configurable backend, app name/label, hidden-target coverage, and table customization.
|
||||
- Full-width typed coverage totals, missing-help links with standard create defaults, and recent changes.
|
||||
- Standard streamed actions: Preview help using runtime help metadata and Check help keys including process suffix wildcards.
|
||||
- Standard `help.read`/`help.insert`/`help.edit`/`help.delete`, `help.hasAccess`, and `help.author.hasAccess` permissions with host-table opt-out; standard bulk edit, import, and export capabilities.
|
||||
- JUnit 5/AssertJ memory-backend integration tests, Checkstyle, JaCoCo gates, Maven 3.9.11 wrapper, and authoring/design documentation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced the extension-template scaffold with `com.kingsrook.qbits.help`, artifact `qbit-help`, version `0.1.0-SNAPSHOT`.
|
||||
- Replaced Apply help changes now with core bundle sync, accept upstream, and keep mine actions, retaining standard Review/Result screens.
|
||||
- Verified against core #1198 `4.1.0-help2-SNAPSHOT`; restored `qqq.version` to `4.1.0-RC.7` with the RC.8 stand-in deadline.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Bundle rows applied counts current persisted provenance, not historical sync execution or cluster reload success. Live host/browser validation remains outstanding.
|
||||
@@ -1,33 +1,17 @@
|
||||
# qbit-help
|
||||
# qbit-template-extension
|
||||
|
||||
QQQ in-app help authoring only. Core owns storage, targeting, search, application, and coverage enumeration. Read README.md, docs/DESIGN.md, and docs/CORE-FOLLOWUPS.md before changing behavior.
|
||||
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
|
||||
|
||||
## Build
|
||||
## QQQ 4.0 baseline — 2026-09-24
|
||||
|
||||
Java 21, `qbit-build-parent` 2.0.0, `qqq.version=4.1.0-RC.11`; the published dependency includes the help-bundle APIs (QRun-IO/qqq#1198), so no local stand-in is required. Use `./mvnw -o verify` (`mvnw.cmd` on Windows), then online if missing artifacts. Maven 3.10 cannot load the current parent's unflattened `${revision}`; wrapper is 3.9.11.
|
||||
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
|
||||
|
||||
JUnit 5 + AssertJ, real QQQ memory backend and streamed process runner. Checkstyle is copied from qbit-webhooks. JaCoCo requires 70% instruction and 90% class coverage. Do not skip gates or exclude authoring classes.
|
||||
## Knowledge base
|
||||
|
||||
## Source map
|
||||
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
|
||||
|
||||
- `HelpQBitConfig`: backend/app/coverage/table customization.
|
||||
- `HelpQBitProducer`: reuse or define core table; app, widgets, actions.
|
||||
- Permissions: standard help CRUD rules, `help.hasAccess`, and `help.author.hasAccess`; host-table opt-out in config.
|
||||
- `HelpWidgets`: seven full-width typed home tables and the conditional record comparison; Next links and standard default-values hash.
|
||||
- `HelpProcesses`: streamed Review/Result workflows and per-run preview metadata.
|
||||
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
|
||||
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Add no tables; use `helpContent`, the approved pre-standard naming exception.
|
||||
- Do not replace host backend details, record locks, or core lifecycle customizers.
|
||||
- One Help app; navigation contains only the help table. Processes are table/record actions.
|
||||
- No HTML widgets, runtime filesystem I/O, other-qbit dependencies, custom routes, or non-UI API paths.
|
||||
- Use QQQ's typed help renderer and standard import/export. No custom dry-run forms.
|
||||
- No global per-instance config cache. Widget metadata carries its own coverage flag.
|
||||
- Do not mutate shared process help for a preview. Use runtime frontend-step adjustments.
|
||||
- Do not wall help off from the assistant.
|
||||
- Preserve user-owned changes. No commits, pushes, PRs, or edits outside this repository without new authorization.
|
||||
|
||||
## Style
|
||||
|
||||
3 spaces, Allman braces, `return (x);`, flower-box Javadoc, three blank lines between methods, sorted imports, fluent `withX()` accessors, reference Apache license header. Favor core primitives and short classes over new abstractions.
|
||||
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
|
||||
@@ -1,130 +1,42 @@
|
||||
# QBit Help
|
||||
# QBit Template: Extension
|
||||
|
||||
The authoring experience for QQQ in-app help: a **Help** app, coverage dashboard, and standard record/table actions. Storage, application, targeting, rendering, and search stay in QQQ core. This qbit adds no tables, filesystem storage, HTTP routes, HTML widgets, or other-qbit dependencies.
|
||||
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
|
||||
|
||||
## Requirements and installation
|
||||
## What is an Extension QBit?
|
||||
|
||||
- Java 21 and `qbit-build-parent` 2.0.0.
|
||||
- QQQ `4.1.0-RC.11` with help bundles (QRun-IO/qqq#1198), available from the configured Maven repository. Full verification and coverage checks run against this published dependency; a local stand-in is no longer required.
|
||||
- A QQQ UI using `/qqq/v1`, including its widget and process endpoints. Next supplies the help renderer and in-context author mode.
|
||||
- A host backend supporting the standard core `helpContent` table. The host owns database schema creation/migration; the producer defines metadata, not database DDL.
|
||||
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
|
||||
- New backend implementations
|
||||
- Authentication mechanisms
|
||||
- Audit capabilities
|
||||
- Table customizers
|
||||
- Action handlers
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-help</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
## Quick Start
|
||||
|
||||
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/main/java/com/kingsrook/qbits/example/
|
||||
├── ExampleExtensionQBitConfig.java # Configuration
|
||||
├── ExampleExtensionQBitProducer.java # Entry point
|
||||
└── customizers/
|
||||
└── ExampleTableCustomizer.java # Table customization
|
||||
```
|
||||
|
||||
This version has not been published. Build/install it locally or use your organization's development artifact repository.
|
||||
## Key Characteristics
|
||||
|
||||
```java
|
||||
new HelpQBitProducer()
|
||||
.withQBitConfig(new HelpQBitConfig()
|
||||
.withBackendName("rdbms"))
|
||||
.produce(qInstance);
|
||||
- **No QAppSection** - Pure infrastructure
|
||||
- **No user-facing tables** (or minimal operational)
|
||||
- **Wraps existing tables/processes** with new behavior
|
||||
- **Validates dependencies** on other QQQ components
|
||||
|
||||
// Then run the host's normal QInstance enrichment/validation and startup.
|
||||
```
|
||||
## Documentation
|
||||
|
||||
If `helpContent` is already registered, `backendName` may be omitted. The existing table, backend mapping, fields, record locks, and core change customizers are retained. The producer sets its visibility, authoring permissions, and standard capabilities. Configure `tableMetaDataCustomizer` for backend-specific details or host customization; it must return the same logical `helpContent` table and preserve core's required fields/customizers.
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Extension Patterns](docs/01-extension-patterns.md)
|
||||
|
||||
| Configuration | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `backendName` | unset | Required only when the host has no `helpContent` table; must name an existing backend. |
|
||||
| `appName` | `help` | App identity. |
|
||||
| `appLabel` | `Help` | App label. |
|
||||
| `includeHiddenInCoverage` | `false` | Include hidden targets in both coverage totals and missing-help rows. |
|
||||
| `applyPermissionRulesToHelpContent` | `true` | Apply help table rules to existing host storage; newly defined storage always receives them. |
|
||||
| `tableMetaDataCustomizer` | unset | Customize the existing or newly defined core table. |
|
||||
## License
|
||||
|
||||
The app's only navigation child is **Help Content**. Preview, key checking, and core bundle sync/accept/keep processes are actions on that table. The defaults for new rows are `ALL_SCREENS` and `MARKDOWN`, unless the host has supplied other defaults.
|
||||
|
||||
## Permissions
|
||||
|
||||
Only standard `QPermissionRules` are used; there is no custom permission checker.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `help.read` | Reading/exporting help rows. |
|
||||
| `help.insert`, `help.edit`, `help.delete` | Corresponding help row operations. |
|
||||
| `help.hasAccess` | Help app and recent-changes widget. |
|
||||
| `help.author.hasAccess` | Coverage and bundle review widgets, preview, key checking, and core sync/accept/keep actions. |
|
||||
|
||||
Give viewers `help.read` and `help.hasAccess`. Give authors both viewer grants plus `help.insert`, `help.edit`, `help.delete`, and `help.author.hasAccess`; author access does not imply any other grant. Core coverage additionally requires insert or edit access to `helpContent`.
|
||||
|
||||
The table uses `READ_INSERT_EDIT_DELETE_PERMISSIONS` with base name `help`. App/recent changes use `HAS_ACCESS_PERMISSION` with base `help`; coverage/processes use the same level with base `help.author`. Core's `PermissionsHelper.getEffectivePermissionSubType` retains `HAS_ACCESS` for non-table checks and `getPermissionName` always appends the subtype suffix, so a base of `help.read` would generate `help.read.hasAccess`, not reuse `help.read`. All six names above are discoverable through standard permission metadata. Viewer table metadata reports read allowed and insert/edit/delete denied; authors receive all four flags.
|
||||
|
||||
By default the qbit applies its table rules even to an existing host table. Set `withApplyPermissionRulesToHelpContent(false)` to preserve the rules on an existing table (including customizer changes). A table newly defined by this qbit always receives the standard help rules. When preserving host rules, grant the host's corresponding table permissions instead.
|
||||
|
||||
The `/qqq/v1` middleware enforces permissions. For direct Java action callers, configure the host's `qInstance.withEnforcePermissionsInCoreActions(true)` as usual; this qbit does not change that instance-wide policy. No assistant/client-channel wall is added. Core enforces individual help-row audience targeting when serving help.
|
||||
|
||||
## Coverage report
|
||||
|
||||
The app home has seven full-width typed `TABLE` widgets:
|
||||
|
||||
- `help_coverage_summary`: total targets, targets with help, and percentage for apps, tables, sections, fields, processes, steps, process fields, reports, widgets, and possible-value sources, plus Overall. Empty kinds show `N/A`; a summary alone counts as help.
|
||||
- `help_coverage_missing`: kind, label, parent key, canonical key, and **Add help**. It displays at most 200 missing targets, with a visible "Showing N of M" count and pagination. Links open Next's standard create screen with `key` and `ALL_SCREENS` prefilled using QQQ's `#defaultValues=` URL mechanism.
|
||||
- `help_recent_changes`: the 20 most recently modified help rows, newest first, with links to their full record screens.
|
||||
|
||||
- `help_bundle_coverage`: each registered bundle's id, version, declared entry count, and rows applied. Here "rows applied" means persisted, resolving `CURRENT` rows attributed to that bundle/version; pending, retired, old-version, and unresolved rows are excluded. It is not a historical sync-run count or proof of cluster-wide reload.
|
||||
- `help_upstream_changed`: pending rows with key, bundle, current/upstream summaries, a short change indicator, and record links.
|
||||
- `help_retired`: edited bundle rows retained by core after retirement, with record links.
|
||||
- `help_unresolved`: rows whose keys no longer resolve using core's `HelpBundleAssertions.resolves`, including its extensible slot and wildcard rules.
|
||||
|
||||
Pending records also have a full-width `help_upstream_diff` TABLE section: Current and Upstream columns, Summary and Content rows. Values are literal text, including Markdown/HTML source. The widget fetches the secured record by id and only returns a comparison for `UPSTREAM_CHANGED`. Other statuses return core's typed hidden-widget response. The section is hidden on edit screens. All review widgets require `help.author.hasAccess` in addition to normal table access.
|
||||
|
||||
Coverage comes from `HelpCoverageAction`. It reflects the current session/client's help visibility, not every audience's combined coverage. Hidden targets are excluded from both numerator and denominator unless configured otherwise. Declared instance-level slots, if present, appear as an additional kind and contribute to Overall. The report counts canonical standard slots; it is not a matrix of every role or targeting variant.
|
||||
|
||||
## Authoring workflow
|
||||
|
||||
1. Open **Help** and use **Add help** in the missing list, or add `?helpHelp` to a Next page to see target keys and in-context **Add help** links. Next implements author mode; no frontend code is bundled here.
|
||||
2. Edit the standard help record: key, role, summary, format, content, and optional permission/client targeting. Prefer a short plain summary and Markdown for longer content.
|
||||
3. On the saved record, choose **Preview help**. Its standard Review and Result steps carry the selected content to Next's normal help renderer; open the step's help icon to view it. A view form shows summary, key, role, format, permission, and client channel. The preview makes the content visible to its author regardless of its intended screen/audience; the original targeting is shown in the form and is not modified.
|
||||
4. Choose **Check help keys** for selected rows or the current query. Review and Result show `N keys resolve`, `N keys do not match any object`, and individual bad keys as standard error lines. This checks target existence, including hidden targets and process suffix wildcards; it does not rewrite records or validate content quality.
|
||||
5. Use **Sync help from bundles** from the Help Content table to seed registered bundles or check newer versions. Core owns its standard Review/Result lines and reload behavior. This replaces the qbit's former **Apply help changes now** action; `help_apply_now` is removed.
|
||||
6. Open an **Upstream changed** row, compare Current and Upstream, then use **Accept upstream text** or **Keep my text**. These are core record actions with Review/Result screens. Core protects edits and rejects a stale pending review if the bundle changes again. Retired and unresolved rows remain available for an author to inspect and edit/delete using normal table actions.
|
||||
|
||||
Normal QQQ inserts/edits/deletes apply through core customizers. Hosts needing a reload after direct database writes can call core's `QInstanceHelpContentManager.reloadHelpContent`; this qbit no longer supplies a separate reload UI action. Cross-node propagation remains core's responsibility.
|
||||
|
||||
## Bulk edit, import and export
|
||||
|
||||
Use QQQ's standard table actions: **Bulk Insert** from file, **Bulk Edit**, **Bulk Edit With File**, and **Export**. The table enables the normal insert/update/delete/query/get/count/export capabilities; core enrichment supplies the bulk processes. This qbit implements no file parser, upload endpoint, or export writer.
|
||||
|
||||
An import should include `key`, `role`, `format`, `summary`, and `content`, plus optional `permissionName` and `clientChannel`. Use the standard edit/import mapping for updating existing rows. Core's unique key is `(key, role)`; targeting is not an extra uniqueness dimension. Run **Check help keys** after an import.
|
||||
|
||||
## Key reference
|
||||
|
||||
Names are metadata names, not display labels. Keys are case-sensitive. Copy canonical keys from the coverage list whenever possible.
|
||||
|
||||
| Target | Key |
|
||||
|---|---|
|
||||
| App | `app:{app};slot:help` |
|
||||
| Table | `table:{table};slot:help` |
|
||||
| Section | `table:{table};section:{section}` |
|
||||
| Field | `table:{table};field:{field}` |
|
||||
| Process | `process:{process};slot:help` |
|
||||
| Frontend process step | `process:{process};step:{step}` |
|
||||
| Process field | `process:{process};field:{field}` |
|
||||
| Report | `report:{report};slot:help` |
|
||||
| Widget title | `widget:{widget};slot:label` |
|
||||
| Possible-value source | `possibleValueSource:{source};slot:help` |
|
||||
| Declared instance slot | `instanceLevel:true;slot:{slot}` |
|
||||
| Process suffix wildcard | `process:*{suffix};step:{step}` (also field/slot keys) |
|
||||
|
||||
Explicitly declared custom slots are accepted by key checking through core's target catalog. Unknown application/plugin-specific key grammars are outside this checker's scope. A wildcard must match at least one real process and the specified step/field/slot; `*` alone is not a valid suffix.
|
||||
|
||||
Roles: `ALL_SCREENS`, `READ_SCREENS`, `WRITE_SCREENS`, `QUERY_SCREEN`, `VIEW_SCREEN`, `EDIT_SCREEN`, `INSERT_SCREEN`, `PROCESS_SCREEN`, `APP_SCREEN`, `TABLE_ACTION_MENU`. Formats: `TEXT`, `MARKDOWN`, `HTML`. Core and the client own rendering and sanitization.
|
||||
|
||||
`helpContent` is a documented **pre-standard naming exception**: this core table predates QBit snake_case naming, and existing applications depend on its name. Do not rename it or create a qbit-prefixed replacement.
|
||||
|
||||
## Build and design
|
||||
|
||||
```sh
|
||||
./mvnw -o -Dqqq.version=4.1.0-help2-SNAPSHOT verify
|
||||
```
|
||||
|
||||
On Windows use `mvnw.cmd`. The wrapper pins Maven 3.9.11 because Maven 3.10 rejects the current build parent's unresolved `${revision}` POM. `mvn -o verify` works with Maven 3.9.11 on PATH. Verification runs JUnit 5/AssertJ memory-backend integration tests, Checkstyle, and JaCoCo gates of 70% instructions and 90% classes.
|
||||
|
||||
See [design](docs/DESIGN.md), [core follow-ups](docs/CORE-FOLLOWUPS.md), and [session state](docs/SESSION-STATE.md). Apache-2.0; see [LICENSE](LICENSE).
|
||||
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
@@ -1,246 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<!--
|
||||
See reference at https://checkstyle.sourceforge.io/checks.html
|
||||
-->
|
||||
<module name = "Checker">
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<!--
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
-->
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder"/>
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap"/>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
-->
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="3"/>
|
||||
<property name="braceAdjustment" value="0"/>
|
||||
<property name="caseIndent" value="3"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="3"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="1"/>
|
||||
</module>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
</module>
|
||||
-->
|
||||
<module name="CustomImportOrder">
|
||||
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
|
||||
<property name="specialImportsRegExp" value="^javax\."/>
|
||||
<property name="standardPackageRegExp" value="^java\."/>
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="false"/>
|
||||
</module>
|
||||
<module name="MethodParamPad"/>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad"/>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation"/>
|
||||
|
||||
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
|
||||
<module name="MissingOverride"/>
|
||||
|
||||
</module>
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@@ -0,0 +1,28 @@
|
||||
# Getting Started
|
||||
|
||||
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
|
||||
|
||||
1. Choose **Use this template** on GitHub, then clone the repository you created.
|
||||
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
|
||||
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
|
||||
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
|
||||
5. Build the generated project:
|
||||
|
||||
```bash
|
||||
mvn clean verify
|
||||
```
|
||||
|
||||
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
|
||||
|
||||
## Register with a host application
|
||||
|
||||
The following uses the original example names; substitute the names chosen above:
|
||||
|
||||
```java
|
||||
new ExampleExtensionQBitProducer()
|
||||
.withConfig(new ExampleExtensionQBitConfig()
|
||||
.withTargetTableName("order"))
|
||||
.produce(qInstance, "my-extension");
|
||||
```
|
||||
|
||||
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
|
||||
@@ -0,0 +1,16 @@
|
||||
# Extension Patterns
|
||||
|
||||
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
|
||||
|
||||
```java
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Target table not found: " + tableName);
|
||||
}
|
||||
new ExampleTableCustomizer().customize(table);
|
||||
```
|
||||
|
||||
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
|
||||
|
||||
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Core follow-ups at 4.1.0-help2-SNAPSHOT
|
||||
|
||||
## Recommended: explicit reload result and failure propagation
|
||||
|
||||
`QInstanceHelpContentManager.reloadHelpContent` returns void and catches/logs exceptions. It also logs/skips invalid targets. Consequently callers cannot obtain an authoritative applied-row count, skipped keys, or success/failure status.
|
||||
|
||||
The qbit now delegates sync to core and no longer exposes a separate reload action. Its bundle coverage counts current persisted provenance, not reload success.
|
||||
|
||||
A core result containing applied row count, rejected rows/reasons, and failure status (or a throwing reload variant) would remove that limitation. Cross-node propagation remains core's responsibility.
|
||||
|
||||
## Build-parent follow-up (outside QQQ core)
|
||||
|
||||
The locally cached qbit-build-parent 2.0.0 POM has `<version>${revision}</version>`. Maven 3.10 rejects it before compilation. The copied reference Maven 3.9.11 wrapper handles it (with a parent warning). Publish a flattened parent POM to remove this toolchain limitation. No global Maven cache or installation was changed for this task.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Help authoring design
|
||||
|
||||
## Boundary
|
||||
|
||||
`qbit-help` is an extension around QQQ core #1177 and #1198. It owns only authoring metadata and action/widget renderers. Core owns `HelpContent`, `QHelpContent`, database customizers, targeting, `HelpCoverageAction`, `HelpTargetCatalog`, `HelpSearchAction`, and `QInstanceHelpContentManager`. Search is therefore not reimplemented here.
|
||||
|
||||
The sole data table is core `helpContent`, an approved pre-standard naming exception. The producer uses `HelpContentMetaDataProvider.withTargetingFields(true)` only when the table is absent. In that case a registered backend is required. Existing storage remains the host's responsibility. The table customizer runs before the qbit applies visibility, capabilities, and permissions; it cannot rename the table. Existing field defaults are retained, otherwise role/format default to ALL_SCREENS/MARKDOWN.
|
||||
|
||||
## Metadata and permissions
|
||||
|
||||
One app defaults to name `help`, label Help, and contains only `helpContent` as a navigation child. The two qbit actions and three core bundle actions carry `tableName=helpContent` and are not app children. Preview requires one selected record. Core bundle sync operates table-wide; accept/keep require selected records. Key checking uses the selected records or current query through `ExtractViaQueryStep`.
|
||||
|
||||
Permissions use only standard `QPermissionRules`. The table uses `READ_INSERT_EDIT_DELETE_PERMISSIONS`, base `help`, generating `help.read`, `help.insert`, `help.edit`, and `help.delete`. The app/recent widget use `HAS_ACCESS_PERMISSION`, base `help`, generating `help.hasAccess`. Coverage/review widgets and all five processes use `HAS_ACCESS_PERMISSION`, base `help.author`, generating `help.author.hasAccess`. Core always appends `.hasAccess` for these non-table checks; it cannot reuse the exact `help.read` name via a base-name override. Authors need all six grants; viewers need read and app access. Core's subtype-aware table metadata checks now accurately deny viewer write flags without core changes.
|
||||
|
||||
The producer applies table rules when it defines the table or `applyPermissionRulesToHelpContent` is true (the default). Opting out on an existing table preserves host/customizer rules. Host record locks remain intact. Widget rendering checks standard widget permissions; core process execution checks process rules. Core coverage additionally requires table insert or edit permission. No denied-client rules are introduced.
|
||||
|
||||
## Dashboard
|
||||
|
||||
All widgets are typed TABLE data at 12 grid columns. Coverage totals are a compact table, with per-kind rows and a fixed overall row. No HTML payload is emitted; row actions are CompositeWidgetData containing TextBlockData with typed links.
|
||||
|
||||
A coverage render requests the complete core coverage action result, removes hidden entries unless configured otherwise, and recomputes totals from those same entries. Thus the missing list and totals have the same denominator and audience. Summary-only entries count. All ten requested kinds appear even when their count is zero; empty scopes have N/A coverage. Core may also supply declared instance slots.
|
||||
|
||||
The missing list is deterministic, bounded to 200 rows, with 20-row pages and a total count in its label. Recent changes uses QueryAction, descending modifyDate and id, limited to 20. The qbit does not add a storage cache.
|
||||
|
||||
Next's links use `AiLinkBuilder` for `/app/{table}` and record URLs. Create defaults follow the JSON/URI-encoded `#defaultValues=` convention found in `AbstractHTMLWidgetRenderer.linkTableCreateWithDefaultValues`. That older helper's app-hierarchy path is not used for Next. Spaces are `%20`, not `+`, because Next decodes hash values with decodeURIComponent. The UI accesses data only via its standard `/qqq/v1` widgets, table, and process endpoints.
|
||||
|
||||
## Actions
|
||||
|
||||
Every action is a StreamedETLWithFrontendProcess with full validation and the standard Review and Result components. Preview and key checking do not write authoring records; core bundle actions own sync and conflict-resolution writes. The row source for preview/check is the normal query extractor with heavy content fields enabled. No files are read or written by the runtime code.
|
||||
|
||||
Preview's transform clones the run's Review/Result frontend steps and attaches a QHelpContent built from the selected record's summary, content, and format. The adjustment is sent through ProcessMetaDataAdjustment.withUpdatedFrontendStepList. Each step retains its normal summary component and adds a VIEW_FORM for the record's key/role/format/summary/targeting. Only the preview payload uses ALL_SCREENS without audience targeting, so an author can preview an EDIT_SCREEN or permission-targeted entry; saved data and shared metadata remain unchanged. No HELP_TEXT fallback is needed.
|
||||
|
||||
Check-keys builds a set from HelpCoverageAction plus core's full HelpTargetCatalog (for explicitly declared nonstandard slots). Hidden targets remain valid. A leading-star process name means a nonempty suffix and must match a real canonical target including its exact step/field/slot. Bad keys appear in standard error summary lines; this diagnostic does not mutate them.
|
||||
|
||||
Core `helpBundleSync`, `helpAcceptUpstream`, and `helpKeepMine` replace the qbit reload action. Their existing standard Review/Result metadata is retained, with `help.author` permission rules applied. No duplicate sync/reload implementation remains.
|
||||
|
||||
## Bundle review
|
||||
|
||||
Home adds full-width upstream-changed, retired, unresolved, and per-bundle coverage tables. All database reads use ordinary secured QQQ actions, including heavy fields for text comparison. Unresolved keys use core `HelpBundleAssertions.resolves` (null legacy keys are unresolved). Per-bundle entries count registered declarations; rows applied counts persisted CURRENT rows at the registered version whose keys resolve. It does not claim a historical run count or successful reload on every node.
|
||||
|
||||
The record comparison follows the qbit record-section pattern: a `QFieldSection` with `widgetName`, tier T2, 12 grid columns, and a hidden RECORD_EDIT alternative. The renderer fetches `helpContent` using the record-view `id` parameter through `GetAction`. It does not trust client-supplied status/text. Pending rows return two plain-text TABLE columns, Current and Upstream, and two labeled rows, Summary and Content. Other statuses/missing records return `AlertData.withHideWidget(true)`, the typed hide signal supported by Next's ConnectedWidget, with no HTML. Shared metadata is never mutated per record.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests use a real memory backend, core instance enrichment/validation, explicit core permission enforcement, ordinary table actions, and direct backend writes to simulate out-of-band edits. The ProcessRunner follows a real UI run through Review and Result. Tests cover canonical and wildcard keys, hidden targets, bounded dashboard data, Next link/default encoding, metadata ownership, saved targeting versus preview presentation, bundle status/version filtering, record diff visibility, malformed keys, read/write permissions, viewer/author metadata flags, and table permission ownership.
|
||||
|
||||
A live browser/host/database deployment has not been exercised in this repository. No frontend implementation or core files are part of this change.
|
||||
@@ -1,21 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Replace template with qbit-help config/producer and core storage reuse.
|
||||
- [x] Typed coverage/missing/recent home widgets and standard create/record links.
|
||||
- [x] Streamed preview/check and core bundle sync/accept/keep actions and standard bulk/import/export features.
|
||||
- [x] Standard help CRUD and access permission rules, discovery, and viewer/author metadata flags.
|
||||
- [x] JUnit 5/AssertJ memory tests, Checkstyle, and JaCoCo gates; offline verify passes.
|
||||
- [x] Authoring, key-reference, design, and local handoff docs.
|
||||
|
||||
- [x] Bundle review tables, secured conditional record comparison, bundle counts, and memory-backend tests.
|
||||
|
||||
## External follow-ups
|
||||
|
||||
- [ ] Core: expose a throwing/result-returning reload API with authoritative counts and rejected rows.
|
||||
- [ ] Confirm published RC.7 contains #1198 bundle APIs and remove the stand-in by RC.8; local help2 verification does not provision hosted CI.
|
||||
- [ ] Build parent: publish a flattened 2.0.0 successor usable by Maven 3.10.
|
||||
- [ ] Run a live Next/host smoke test at narrow and wide widths, including permission flags, preview help, default-value links, and import/export.
|
||||
|
||||
No commits, pushes, PRs, deployments, or changes outside this worktree are authorized by this task.
|
||||
@@ -1,295 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -1,189 +0,0 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -1,87 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-RC.2</revision>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-help</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Help</name>
|
||||
<description>Authoring and coverage for QQQ in-app help</description>
|
||||
<url>https://github.com/QRun-IO/qbit-help</url>
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-example-extension</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
<name>QBit Example Extension</name>
|
||||
<description>Example Extension QBit - rename this for your QBit</description>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-help.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-help.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-help</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>5.11.4</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@@ -89,9 +26,11 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -106,85 +45,14 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.12.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<headerLocation>checkstyle/license.txt</headerLocation>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
@@ -0,0 +1,75 @@
|
||||
/*******************************************************************************
|
||||
** Configuration for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitConfig implements QBitConfig
|
||||
{
|
||||
private String targetTableName;
|
||||
private Boolean enableFeatureX = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate configuration before QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
|
||||
{
|
||||
errors.add("Target table not found: " + targetTableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTableName
|
||||
*******************************************************************************/
|
||||
public String getTargetTableName()
|
||||
{
|
||||
return targetTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTableName
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
|
||||
{
|
||||
this.targetTableName = targetTableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public Boolean getEnableFeatureX()
|
||||
{
|
||||
return enableFeatureX;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
|
||||
{
|
||||
this.enableFeatureX = enableFeatureX;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-extension";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleExtensionQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce this QBit into the given QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
if(config != null && config.getTargetTableName() != null)
|
||||
{
|
||||
applyTableCustomizations(qInstance, config.getTargetTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the target table.
|
||||
*******************************************************************************/
|
||||
private void applyTableCustomizations(QInstance qInstance, String tableName)
|
||||
{
|
||||
// Implement your extension logic here
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for config
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*******************************************************************************
|
||||
** Example table customizer.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.customizers;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Example table customizer that adds behavior to existing tables.
|
||||
*******************************************************************************/
|
||||
public class ExampleTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the given table.
|
||||
*******************************************************************************/
|
||||
public void customize(QTableMetaData table)
|
||||
{
|
||||
// Example: Add audit tracking fields
|
||||
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageEntry;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessMetaDataAdjustment;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpRole;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed actions: Review, execution, and Result.
|
||||
*******************************************************************************/
|
||||
public final class HelpProcesses
|
||||
{
|
||||
public static final String PREVIEW = "help_preview";
|
||||
public static final String CHECK = "help_check_keys";
|
||||
private static final List<String> PREVIEW_FIELDS = List.of("summary", "key", "role", "format", "permissionName", "clientChannel");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private HelpProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table-bound processes are discovered as actions, never as app children.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> metadata()
|
||||
{
|
||||
return (List.of(
|
||||
etl(PREVIEW, "Preview help", ExtractViaQueryStep.class, PreviewTransform.class, ReadOnlyLoad.class)
|
||||
.withMinInputRecords(1).withMaxInputRecords(1),
|
||||
etl(CHECK, "Check help keys", ExtractViaQueryStep.class, CheckTransform.class, ReadOnlyLoad.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared streamed ETL definition; full validation covers every selected row.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData etl(String name, String label, Class<? extends AbstractExtractStep> extract,
|
||||
Class<? extends AbstractTransformStep> transform, Class<? extends AbstractLoadStep> load)
|
||||
{
|
||||
Map<String, Serializable> defaults = new HashMap<>();
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, HelpContent.TABLE_NAME);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, HelpContent.TABLE_NAME);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_FETCH_HEAVY_FIELDS, true);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
|
||||
defaults.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label);
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(extract, transform, load, defaults)
|
||||
.withName(name).withLabel(label).withIcon(new QIcon("help_outline"))
|
||||
.withTableName(HelpContent.TABLE_NAME).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("help.author")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical catalog, including hidden objects and explicitly declared slots.
|
||||
*******************************************************************************/
|
||||
static Set<String> keys() throws QException
|
||||
{
|
||||
Set<String> keys = new HelpCoverageAction().execute(new HelpCoverageInput()).getEntries().stream()
|
||||
.map(HelpCoverageEntry::getKey).collect(Collectors.toSet());
|
||||
HelpTargetCatalog.collect(QContext.getQInstance()).stream().map(HelpTarget::key).forEach(keys::add);
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve canonical keys or core's leading-star process suffix wildcard.
|
||||
*******************************************************************************/
|
||||
static boolean resolves(String key, Set<String> keys)
|
||||
{
|
||||
if(key == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(keys.contains(key))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(key.startsWith("process:*"))
|
||||
{
|
||||
int separator = key.indexOf(';');
|
||||
if(separator > "process:*".length())
|
||||
{
|
||||
String suffix = key.substring("process:*".length(), separator);
|
||||
String target = key.substring(separator);
|
||||
return (keys.stream().anyMatch(candidate -> candidate.startsWith("process:")
|
||||
&& candidate.endsWith(suffix + target)));
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard count line with stable Review and Result wording.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, int count, String message)
|
||||
{
|
||||
return (new ProcessSummaryLine(status, count, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Key validation never writes data; bad keys are standard error lines.
|
||||
*******************************************************************************/
|
||||
public static class CheckTransform extends AbstractTransformStep
|
||||
{
|
||||
private Set<String> keys;
|
||||
private int good;
|
||||
private final List<String> bad = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate a page against one catalog snapshot.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(keys == null)
|
||||
{
|
||||
keys = keys();
|
||||
}
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String key = record.getValueString("key");
|
||||
if(resolves(key, keys))
|
||||
{
|
||||
good++;
|
||||
}
|
||||
else
|
||||
{
|
||||
bad.add(String.valueOf(key));
|
||||
}
|
||||
output.addRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both totals remain visible even when one count is zero.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
lines.add(line(Status.OK, good, "keys resolve"));
|
||||
lines.add(line(bad.isEmpty() ? Status.INFO : Status.ERROR, bad.size(), "keys do not match any object"));
|
||||
for(String key : bad)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.ERROR, null, key));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview a single selected record through the client's normal help renderer.
|
||||
*******************************************************************************/
|
||||
public static class PreviewTransform extends AbstractTransformStep
|
||||
{
|
||||
private QRecord selected;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep exactly one record, including its heavy content field.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
if(selected != null)
|
||||
{
|
||||
throw (new QException("Preview help requires exactly one record"));
|
||||
}
|
||||
selected = record;
|
||||
output.addRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adjust only this process run, never shared process metadata.
|
||||
** Targeting is displayed as data; the preview is visible to the author.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void postRun(BackendStepPostRunInput input, BackendStepPostRunOutput output) throws QException
|
||||
{
|
||||
if(selected == null)
|
||||
{
|
||||
throw (new QException("Preview help requires exactly one record"));
|
||||
}
|
||||
QHelpContent help = new QHelpContent().withContent(selected.getValueString("content"))
|
||||
.withSummary(selected.getValueString("summary")).withRole(QHelpRole.ALL_SCREENS);
|
||||
String format = selected.getValueString("format");
|
||||
if(format != null && !format.isBlank())
|
||||
{
|
||||
help.withFormat(HelpFormat.valueOf(format));
|
||||
}
|
||||
List<QFrontendStepMetaData> steps = new ArrayList<>();
|
||||
for(String name : List.of(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW, StreamedETLWithFrontendProcess.STEP_NAME_RESULT))
|
||||
{
|
||||
QFrontendStepMetaData step = QContext.getQInstance().getProcess(PREVIEW).getFrontendStep(name).clone();
|
||||
step.setHelpContents(List.of(help));
|
||||
step.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM));
|
||||
for(String field : PREVIEW_FIELDS)
|
||||
{
|
||||
QFieldMetaData source = QContext.getQInstance().getTable(HelpContent.TABLE_NAME).getFields().get(field);
|
||||
step.withViewField(source == null ? new QFieldMetaData(field, QFieldType.STRING).withLabel(field) : source.clone());
|
||||
output.addValue(field, selected.getValue(field));
|
||||
}
|
||||
steps.add(step);
|
||||
}
|
||||
output.setProcessMetaDataAdjustment(new ProcessMetaDataAdjustment().withUpdatedFrontendStepList(steps));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard read-only preview summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (new ArrayList<>(List.of(line(Status.INFO, selected == null ? 0 : 1, "help entry previewed"))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only execution keeps the transform's standard summary.
|
||||
*******************************************************************************/
|
||||
public static class ReadOnlyLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Pass records through without writing them.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse validation results.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
return (getTransformStep().getProcessSummary(output, result));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration for the Help authoring app.
|
||||
*******************************************************************************/
|
||||
public class HelpQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private String appName = "help";
|
||||
private String appLabel = "Help";
|
||||
private boolean includeHiddenInCoverage = false;
|
||||
private boolean applyPermissionRulesToHelpContent = true;
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get backendName.
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set backendName.
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get appName.
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (appName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set appName.
|
||||
*******************************************************************************/
|
||||
public void setAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get appLabel.
|
||||
*******************************************************************************/
|
||||
public String getAppLabel()
|
||||
{
|
||||
return (appLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set appLabel.
|
||||
*******************************************************************************/
|
||||
public void setAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appLabel.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get applyPermissionRulesToHelpContent.
|
||||
*******************************************************************************/
|
||||
public boolean getApplyPermissionRulesToHelpContent()
|
||||
{
|
||||
return (applyPermissionRulesToHelpContent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set applyPermissionRulesToHelpContent.
|
||||
*******************************************************************************/
|
||||
public void setApplyPermissionRulesToHelpContent(boolean applyPermissionRulesToHelpContent)
|
||||
{
|
||||
this.applyPermissionRulesToHelpContent = applyPermissionRulesToHelpContent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for applyPermissionRulesToHelpContent.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withApplyPermissionRulesToHelpContent(boolean applyPermissionRulesToHelpContent)
|
||||
{
|
||||
this.applyPermissionRulesToHelpContent = applyPermissionRulesToHelpContent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get includeHiddenInCoverage.
|
||||
*******************************************************************************/
|
||||
public boolean getIncludeHiddenInCoverage()
|
||||
{
|
||||
return (includeHiddenInCoverage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set includeHiddenInCoverage.
|
||||
*******************************************************************************/
|
||||
public void setIncludeHiddenInCoverage(boolean includeHiddenInCoverage)
|
||||
{
|
||||
this.includeHiddenInCoverage = includeHiddenInCoverage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for includeHiddenInCoverage.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withIncludeHiddenInCoverage(boolean includeHiddenInCoverage)
|
||||
{
|
||||
this.includeHiddenInCoverage = includeHiddenInCoverage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
|
||||
{
|
||||
return (tableMetaDataCustomizer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public void setTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
|
||||
{
|
||||
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public HelpQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
|
||||
{
|
||||
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the backend only when core help storage must be defined.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(instance.getTable(HelpContent.TABLE_NAME) == null)
|
||||
{
|
||||
assertCondition(backendName != null && instance.getBackend(backendName) != null,
|
||||
"backendName must name an existing backend when helpContent is absent", errors);
|
||||
}
|
||||
assertCondition(appName != null && !appName.isBlank(), "appName must not be blank", errors);
|
||||
assertCondition(appLabel != null && !appLabel.isBlank(), "appLabel must not be blank", errors);
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContentMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the authoring app around core's existing helpContent table.
|
||||
*******************************************************************************/
|
||||
public class HelpQBitProducer implements QBitMetaDataProducer<HelpQBitConfig>
|
||||
{
|
||||
private HelpQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply configuration explicitly; package scanning alone is disabled.
|
||||
*******************************************************************************/
|
||||
public HelpQBitProducer withQBitConfig(HelpQBitConfig config)
|
||||
{
|
||||
qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HelpQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace for QBit identity (storage and action names remain stable).
|
||||
*******************************************************************************/
|
||||
public HelpQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBit identity.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-help")
|
||||
.withVersion("0.1.0-SNAPSHOT").withNamespace(namespace).withConfig(qBitConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only explicitly configured producers run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install metadata into the instance, preserving host storage and customizers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
|
||||
{
|
||||
if(qBitConfig == null)
|
||||
{
|
||||
throw (new QException("Help qbit requires HelpQBitConfig"));
|
||||
}
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(instance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("Help qbit configuration is invalid: " + String.join("; ", errors)));
|
||||
}
|
||||
boolean definesHelpContent = instance.getTable(HelpContent.TABLE_NAME) == null;
|
||||
if(definesHelpContent)
|
||||
{
|
||||
new HelpContentMetaDataProvider().withTargetingFields(true).defineAll(instance, qBitConfig.getBackendName(), null);
|
||||
}
|
||||
QTableMetaData table = instance.getTable(HelpContent.TABLE_NAME);
|
||||
if(qBitConfig.getTableMetaDataCustomizer() != null)
|
||||
{
|
||||
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(instance, table);
|
||||
}
|
||||
if(table == null || !HelpContent.TABLE_NAME.equals(table.getName()))
|
||||
{
|
||||
throw (new QException("tableMetaDataCustomizer must return the helpContent table without renaming it"));
|
||||
}
|
||||
if(definesHelpContent || qBitConfig.getApplyPermissionRulesToHelpContent())
|
||||
{
|
||||
table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
|
||||
.withPermissionBaseName("help"));
|
||||
}
|
||||
table.withIsHidden(false)
|
||||
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT, Capability.TABLE_INSERT,
|
||||
Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.TABLE_EXPORT);
|
||||
if(table.getField("format").getDefaultValue() == null)
|
||||
{
|
||||
table.getField("format").withDefaultValue("MARKDOWN");
|
||||
}
|
||||
if(table.getField("role").getDefaultValue() == null)
|
||||
{
|
||||
table.getField("role").withDefaultValue("ALL_SCREENS");
|
||||
}
|
||||
table.withSection(new QFieldSection().withName(HelpWidgets.DIFF).withLabel("Current and upstream help")
|
||||
.withTier(Tier.T2).withGridColumns(12).withWidgetName(HelpWidgets.DIFF)
|
||||
.withAlternative(QFieldSectionAlternativeType.RECORD_EDIT, section -> section.withIsHidden(true)));
|
||||
for(String name : List.of("helpBundleSync", "helpAcceptUpstream", "helpKeepMine"))
|
||||
{
|
||||
if(instance.getProcess(name) != null)
|
||||
{
|
||||
instance.getProcess(name).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("help.author"));
|
||||
}
|
||||
}
|
||||
instance.getTables().put(HelpContent.TABLE_NAME, table);
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
instance.addQBit(qBit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
HelpWidgets.metadata(qBitConfig).forEach(output::add);
|
||||
HelpProcesses.metadata().forEach(process ->
|
||||
{
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
});
|
||||
output.add(new QAppMetaData().withName(qBitConfig.getAppName()).withLabel(qBitConfig.getAppLabel())
|
||||
.withIcon(new QIcon("help_outline")).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("help"))
|
||||
.withWidgets(HelpWidgets.HOME).withChild(table));
|
||||
output.addSelfToInstance(instance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageEntry;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed, full-width home widgets served by the standard UI widget endpoint.
|
||||
*******************************************************************************/
|
||||
public class HelpWidgets extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String SUMMARY = "help_coverage_summary";
|
||||
public static final String MISSING = "help_coverage_missing";
|
||||
public static final String RECENT = "help_recent_changes";
|
||||
public static final String UPSTREAM = "help_upstream_changed";
|
||||
public static final String RETIRED = "help_retired";
|
||||
public static final String UNRESOLVED = "help_unresolved";
|
||||
public static final String BUNDLES = "help_bundle_coverage";
|
||||
public static final String DIFF = "help_upstream_diff";
|
||||
public static final List<String> HOME = List.of(SUMMARY, BUNDLES, UPSTREAM, RETIRED, UNRESOLVED, MISSING, RECENT);
|
||||
public static final int MISSING_LIMIT = 200;
|
||||
private static final Map<String, String> KIND_LABELS = Map.ofEntries(
|
||||
Map.entry("app", "Apps"), Map.entry("table", "Tables"), Map.entry("section", "Sections"),
|
||||
Map.entry("field", "Fields"), Map.entry("process", "Processes"), Map.entry("step", "Steps"),
|
||||
Map.entry("processField", "Process fields"), Map.entry("report", "Reports"),
|
||||
Map.entry("widget", "Widgets"), Map.entry("possibleValueSource", "Possible-value sources"));
|
||||
private static final List<String> KINDS = List.of("app", "table", "section", "field", "process", "step",
|
||||
"processField", "report", "widget", "possibleValueSource");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full-width metadata with configuration carried per instance, not globally.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> metadata(HelpQBitConfig config)
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
for(String name : List.of(SUMMARY, MISSING, RECENT, BUNDLES, UPSTREAM, RETIRED, UNRESOLVED, DIFF))
|
||||
{
|
||||
String label = switch(name)
|
||||
{
|
||||
case SUMMARY -> "Help coverage";
|
||||
case MISSING -> "Missing help";
|
||||
case BUNDLES -> "Help bundle coverage";
|
||||
case UPSTREAM -> "Upstream changed";
|
||||
case RETIRED -> "Retired";
|
||||
case UNRESOLVED -> "Keys that no longer resolve";
|
||||
case DIFF -> "Current and upstream help";
|
||||
default -> "Recent changes";
|
||||
};
|
||||
widgets.add(new QWidgetMetaData().withName(name).withLabel(label).withType(WidgetType.TABLE.getType())
|
||||
.withCodeReference(new QCodeReference(HelpWidgets.class)).withGridColumns(12)
|
||||
.withDefaultValue("includeHidden", config.getIncludeHiddenInCoverage()).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName(RECENT.equals(name) ? "help" : "help.author")));
|
||||
}
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render using core actions, with no new HTTP surface.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
PermissionsHelper.checkWidgetPermissionThrowing(input, input.getWidgetMetaData().getName());
|
||||
if(RECENT.equals(input.getWidgetMetaData().getName()))
|
||||
{
|
||||
return (new RenderWidgetOutput(recent()));
|
||||
}
|
||||
String name = input.getWidgetMetaData().getName();
|
||||
if(DIFF.equals(name))
|
||||
{
|
||||
return (new RenderWidgetOutput(diff(input)));
|
||||
}
|
||||
if(List.of(BUNDLES, UPSTREAM, RETIRED, UNRESOLVED).contains(name))
|
||||
{
|
||||
List<QRecord> records = new QueryAction().execute(new QueryInput(HelpContent.TABLE_NAME)
|
||||
.withShouldFetchHeavyFields(true).withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)))).getRecords();
|
||||
return (new RenderWidgetOutput(BUNDLES.equals(name) ? bundles(records) : review(name, records)));
|
||||
}
|
||||
boolean hidden = Boolean.TRUE.equals(input.getWidgetMetaData().getDefaultValues().get("includeHidden"));
|
||||
List<HelpCoverageEntry> entries = new HelpCoverageAction().execute(new HelpCoverageInput()).getEntries().stream()
|
||||
.filter(entry -> hidden || !entry.getHidden()).toList();
|
||||
return (new RenderWidgetOutput(MISSING.equals(input.getWidgetMetaData().getName()) ? missing(entries) : summary(entries)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recount filtered entries so hidden targets affect neither numerator nor denominator.
|
||||
*******************************************************************************/
|
||||
private static TableData summary(List<HelpCoverageEntry> entries)
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
List<String> kinds = new ArrayList<>(KINDS);
|
||||
entries.stream().map(HelpCoverageEntry::getKind).distinct().filter(kind -> !kinds.contains(kind)).sorted().forEach(kinds::add);
|
||||
for(String kind : kinds)
|
||||
{
|
||||
List<HelpCoverageEntry> scoped = entries.stream().filter(entry -> kind.equals(entry.getKind())).toList();
|
||||
rows.add(total(KIND_LABELS.getOrDefault(kind, kind), scoped));
|
||||
}
|
||||
rows.add(total("Overall", entries));
|
||||
return (new TableData("Targets with help", List.of(column("Kind", "kind"), column("Total", "total"),
|
||||
column("With help", "withHelp"), column("Coverage", "percent")), rows)
|
||||
.withRowsPerPage(rows.size()).withHidePaginationDropdown(true).withFixedStickyLastRow(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One totals row. Empty scopes have no coverage percentage.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> total(String label, List<HelpCoverageEntry> entries)
|
||||
{
|
||||
long withHelp = entries.stream().filter(entry -> entry.getHasContent() || entry.getHasSummary()).count();
|
||||
return (Map.of("kind", label, "total", entries.size(), "withHelp", withHelp, "percent",
|
||||
entries.isEmpty() ? "N/A" : String.format(Locale.ROOT, "%.1f%%", 100.0 * withHelp / entries.size())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded missing list with the full missing count, and core default-value links.
|
||||
*******************************************************************************/
|
||||
private static TableData missing(List<HelpCoverageEntry> entries) throws QException
|
||||
{
|
||||
List<HelpCoverageEntry> missing = entries.stream().filter(entry -> !entry.getHasContent() && !entry.getHasSummary()).toList();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(HelpCoverageEntry entry : missing.stream().limit(MISSING_LIMIT).toList())
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("kind", KIND_LABELS.getOrDefault(entry.getKind(), entry.getKind()));
|
||||
row.put("label", entry.getLabel());
|
||||
row.put("parent", entry.getParentKey());
|
||||
row.put("key", entry.getKey());
|
||||
String defaults = JsonUtils.toJson(Map.of("key", entry.getKey(), "role", "ALL_SCREENS"));
|
||||
String url = AiLinkBuilder.objectHref(HelpContent.TABLE_NAME) + "/create#defaultValues="
|
||||
+ URLEncoder.encode(defaults, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
row.put("action", link("Add help", url));
|
||||
rows.add(row);
|
||||
}
|
||||
return (new TableData("Showing " + rows.size() + " of " + missing.size() + " targets missing help",
|
||||
List.of(column("Kind", "kind"), column("Label", "label"), column("Parent", "parent"), column("Key", "key"),
|
||||
column("Action", "action").withType("composite")), rows).withRowsPerPage(20));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest standard records; QueryAction enforces host record security as well.
|
||||
*******************************************************************************/
|
||||
private static TableData recent() throws QException
|
||||
{
|
||||
List<QRecord> records = new QueryAction().execute(new QueryInput().withTableName(HelpContent.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("modifyDate", false))
|
||||
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(20))).getRecords();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("key", record.getValue("key"));
|
||||
row.put("summary", record.getValue("summary"));
|
||||
row.put("role", record.getValue("role"));
|
||||
row.put("modified", record.getValueString("modifyDate"));
|
||||
row.put("record", link("View help", AiLinkBuilder.recordHref(HelpContent.TABLE_NAME, record.getValue("id"))));
|
||||
rows.add(row);
|
||||
}
|
||||
return (new TableData("Most recent " + records.size() + " help entries", List.of(column("Key", "key"),
|
||||
column("Summary", "summary"), column("Role", "role"), column("Modified", "modified"),
|
||||
column("Record", "record").withType("composite")), rows).withRowsPerPage(20));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pending and retired rows remain ordinary record links with core actions.
|
||||
*******************************************************************************/
|
||||
private static TableData review(String name, List<QRecord> records)
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
boolean include = switch(name)
|
||||
{
|
||||
case UPSTREAM -> "UPSTREAM_CHANGED".equals(record.getValueString("upstreamStatus"));
|
||||
case RETIRED -> "RETIRED".equals(record.getValueString("upstreamStatus"));
|
||||
default -> !resolves(record);
|
||||
};
|
||||
if(!include)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for(String field : List.of("key", "bundleId", "summary", "upstreamSummary"))
|
||||
{
|
||||
row.put(field, record.getValue(field));
|
||||
}
|
||||
List<String> changes = new ArrayList<>();
|
||||
for(String field : List.of("summary", "content"))
|
||||
{
|
||||
String upstream = "upstream" + Character.toUpperCase(field.charAt(0)) + field.substring(1);
|
||||
if(!Objects.equals(record.getValueString(field), record.getValueString(upstream)))
|
||||
{
|
||||
changes.add(field + " changed");
|
||||
}
|
||||
}
|
||||
row.put("changes", String.join(", ", changes));
|
||||
row.put("record", link("View help", AiLinkBuilder.recordHref(HelpContent.TABLE_NAME, record.getValue("id"))));
|
||||
rows.add(row);
|
||||
}
|
||||
List<TableData.Column> columns = new ArrayList<>(List.of(column("Key", "key"), column("Bundle", "bundleId"), column("Current summary", "summary")));
|
||||
if(UPSTREAM.equals(name))
|
||||
{
|
||||
columns.add(column("Upstream summary", "upstreamSummary"));
|
||||
columns.add(column("Changes", "changes"));
|
||||
}
|
||||
columns.add(column("Record", "record").withType("composite"));
|
||||
return (new TableData(rows.size() + " help entries", columns, rows).withRowsPerPage(20));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts persisted current rows of each registered bundle version.
|
||||
*******************************************************************************/
|
||||
private static TableData bundles(List<QRecord> records)
|
||||
{
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(var bundle : HelpBundles.of(QContext.getQInstance()).getBundles())
|
||||
{
|
||||
long applied = records.stream().filter(record -> bundle.getId().equals(record.getValueString("bundleId"))
|
||||
&& bundle.getVersion().equals(record.getValueString("bundleVersion"))
|
||||
&& "CURRENT".equals(record.getValueString("upstreamStatus"))
|
||||
&& resolves(record)).count();
|
||||
rows.add(Map.of("bundle", bundle.getId(), "version", bundle.getVersion(), "entries", bundle.getEntries().size(), "applied", applied));
|
||||
}
|
||||
return (new TableData("Registered bundles (rows applied at the current version)", List.of(column("Bundle", "bundle"),
|
||||
column("Version", "version"), column("Entries", "entries"), column("Rows applied", "applied")), rows).withRowsPerPage(20));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core owns key grammar; malformed legacy rows can have a null key.
|
||||
*******************************************************************************/
|
||||
private static boolean resolves(QRecord record)
|
||||
{
|
||||
String key = record.getValueString("key");
|
||||
return (key != null && HelpBundleAssertions.resolves(QContext.getQInstance(), key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fetch the secured record, never trust status or content from query parameters.
|
||||
** A typed hidden response suppresses the widget outside pending reviews.
|
||||
*******************************************************************************/
|
||||
private static QWidgetData diff(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String id = input.getQueryParams().get("id");
|
||||
QRecord record = id == null || id.isBlank() ? null : new GetAction().execute(new GetInput(HelpContent.TABLE_NAME)
|
||||
.withPrimaryKey(id).withShouldFetchHeavyFields(true)).getRecord();
|
||||
if(record == null || !"UPSTREAM_CHANGED".equals(record.getValueString("upstreamStatus")))
|
||||
{
|
||||
return (new AlertData().withHideWidget(true));
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(String field : List.of("Summary", "Content"))
|
||||
{
|
||||
rows.add(Map.of("current", field + ":\n" + Objects.toString(record.getValueString(field.toLowerCase(Locale.ROOT)), ""),
|
||||
"upstream", field + ":\n" + Objects.toString(record.getValueString("upstream" + field), "")));
|
||||
}
|
||||
return (new TableData("Compare Summary and Content before accepting upstream or keeping yours",
|
||||
List.of(column("Current", "current"), column("Upstream", "upstream")), rows)
|
||||
.withRowsPerPage(2).withHidePaginationDropdown(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard plain-text column.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String label, String accessor)
|
||||
{
|
||||
return (new TableData.Column("default", label, accessor, null, "left"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed text/link blocks, never HTML markup.
|
||||
*******************************************************************************/
|
||||
private static CompositeWidgetData link(String label, String url)
|
||||
{
|
||||
return (new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_ROW)
|
||||
.withBlock(new TextBlockData().withValues(new TextValues(label)).withLink(url)));
|
||||
}
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpCoverageInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.TableMetaDataAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.helpcontent.HelpContentMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpRole;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryInsertAction;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memory-backend integration tests for metadata, widgets and real process runs.
|
||||
*******************************************************************************/
|
||||
class HelpQBitTest
|
||||
{
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build and validate a fresh memory instance before every test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
MemoryRecordStore.fullReset();
|
||||
instance = base();
|
||||
new HelpQBitProducer().withQBitConfig(new HelpQBitConfig().withBackendName("memory")).produce(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
as("help.read", "help.insert", "help.edit", "help.delete", "help.hasAccess", "help.author.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host instance with ordinary, visible and hidden field targets.
|
||||
*******************************************************************************/
|
||||
private static QInstance base()
|
||||
{
|
||||
QInstance instance = new QInstance().withEnforcePermissionsInCoreActions(true);
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName("person").withLabel("People").withBackendName("memory")
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("hidden", QFieldType.STRING).withIsHidden(true))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run as a session with exactly the supplied permissions.
|
||||
*******************************************************************************/
|
||||
private void as(String... permissions)
|
||||
{
|
||||
QContext.init(instance, new QSession().withPermissions(List.of(permissions)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A persisted-help fixture with a stable modification timestamp.
|
||||
*******************************************************************************/
|
||||
private static QRecord help(int id, String key)
|
||||
{
|
||||
return (new QRecord().withValue("id", id).withValue("key", key).withValue("role", "ALL_SCREENS")
|
||||
.withValue("format", "MARKDOWN").withValue("summary", "Short help " + id)
|
||||
.withValue("content", "Read **this** first.").withValue("modifyDate", Instant.parse("2026-10-13T10:00:00Z")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a typed home widget in the active context.
|
||||
*******************************************************************************/
|
||||
private TableData widget(String name) throws Exception
|
||||
{
|
||||
return ((TableData) new HelpWidgets().render(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(name))).getWidgetData());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a typed link from a table cell.
|
||||
*******************************************************************************/
|
||||
private static String href(Map<String, Object> row, String field)
|
||||
{
|
||||
return (((CompositeWidgetData) row.get(field)).getBlocks().getFirst().getLink().getHref());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata Uses Only Core Table And Standard Actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataUsesOnlyCoreTableAndStandardActions() throws Exception
|
||||
{
|
||||
assertThat(instance.getTables()).containsOnlyKeys("person", "helpContent");
|
||||
assertThat(instance.getApp("help").getLabel()).isEqualTo("Help");
|
||||
assertThat(instance.getApp("help").getChildren()).containsExactly(instance.getTable("helpContent"));
|
||||
assertThat(instance.getApp("help").getWidgets()).containsExactlyElementsOf(HelpWidgets.HOME);
|
||||
assertThat(instance.getWidgets().values()).allSatisfy(widget ->
|
||||
{
|
||||
assertThat(widget.getType()).isEqualTo(WidgetType.TABLE.getType());
|
||||
assertThat(widget.getGridColumns()).isEqualTo(12);
|
||||
});
|
||||
assertThat(instance.getProcesses()).containsKeys(HelpProcesses.PREVIEW, "helpBundleSync", HelpProcesses.CHECK,
|
||||
"helpAcceptUpstream", "helpKeepMine", "helpContent.bulkInsert", "helpContent.bulkEdit", "helpContent.bulkEditWithFile");
|
||||
assertThat(List.of(instance.getProcess(HelpProcesses.PREVIEW), instance.getProcess("helpBundleSync"), instance.getProcess("helpAcceptUpstream"), instance.getProcess("helpKeepMine"), instance.getProcess(HelpProcesses.CHECK))).allSatisfy(process ->
|
||||
{
|
||||
assertThat(process.getTableName()).isEqualTo("helpContent");
|
||||
assertThat(process.getFrontendStep("review")).isNotNull();
|
||||
assertThat(process.getFrontendStep("result")).isNotNull();
|
||||
});
|
||||
assertThat(instance.getProcess(HelpProcesses.PREVIEW).getMinInputRecords()).isEqualTo(1);
|
||||
assertThat(instance.getProcess(HelpProcesses.PREVIEW).getMaxInputRecords()).isEqualTo(1);
|
||||
assertThat(instance.getProcesses()).doesNotContainKey("help_apply_now");
|
||||
var table = instance.getTable("helpContent");
|
||||
assertThat(table.getIsHidden()).isFalse();
|
||||
assertThat(table.getFields()).containsKeys("summary", "permissionName", "clientChannel");
|
||||
assertThat(table.getCustomizers()).isNotEmpty();
|
||||
assertThat(table.getField("format").getDefaultValue()).isEqualTo("MARKDOWN");
|
||||
assertThat(table.getField("role").getDefaultValue()).isEqualTo("ALL_SCREENS");
|
||||
assertThat(table.getEnabledCapabilities()).contains(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_EXPORT);
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissions(instance)).extracting(permission -> permission.getName())
|
||||
.contains("help.read", "help.insert", "help.edit", "help.delete", "help.hasAccess", "help.author.hasAccess")
|
||||
.doesNotContain("help.view", "help.author", "help.read.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing Table Is Reused Without Backend Config.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void existingTableIsReusedWithoutBackendConfig() throws Exception
|
||||
{
|
||||
QInstance host = base();
|
||||
new HelpContentMetaDataProvider().defineAll(host, "memory", null);
|
||||
QTableMetaData table = host.getTable("helpContent");
|
||||
table.withLabel("Host help").withField(new QFieldMetaData("hostField", QFieldType.STRING));
|
||||
var customizers = table.getCustomizers();
|
||||
new HelpQBitProducer().withQBitConfig(new HelpQBitConfig().withAppName("support").withAppLabel("Support")
|
||||
.withTableMetaDataCustomizer((q, t) -> t.withRecordLabelFormat("Help %s"))).produce(host);
|
||||
assertThat(host.getTable("helpContent")).isSameAs(table);
|
||||
assertThat(table.getLabel()).isEqualTo("Host help");
|
||||
assertThat(table.getField("hostField")).isNotNull();
|
||||
assertThat(table.getCustomizers()).isSameAs(customizers);
|
||||
assertThat(host.getApp("support").getLabel()).isEqualTo("Support");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid Config Fails Clearly Before Creating Anything.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidConfigFailsClearlyBeforeCreatingAnything()
|
||||
{
|
||||
QInstance empty = base();
|
||||
assertThatThrownBy(() -> new HelpQBitProducer().produce(empty)).hasMessageContaining("HelpQBitConfig");
|
||||
assertThatThrownBy(() -> new HelpQBitProducer().withQBitConfig(new HelpQBitConfig()).produce(empty))
|
||||
.hasMessageContaining("backendName").hasMessageContaining("helpContent is absent");
|
||||
assertThat(empty.getTable("helpContent")).isNull();
|
||||
assertThatThrownBy(() -> new HelpQBitProducer().withQBitConfig(new HelpQBitConfig().withBackendName("memory")
|
||||
.withAppName(" ").withAppLabel(null)).produce(empty)).hasMessageContaining("appName").hasMessageContaining("appLabel");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Config And Producer Identity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configAndProducerIdentity()
|
||||
{
|
||||
HelpQBitConfig config = new HelpQBitConfig();
|
||||
assertThat(config.getIncludeHiddenInCoverage()).isFalse();
|
||||
config.setBackendName("memory");
|
||||
config.setAppName("support");
|
||||
config.setAppLabel("Support");
|
||||
config.setIncludeHiddenInCoverage(true);
|
||||
config.setTableMetaDataCustomizer((q, t) -> t);
|
||||
assertThat(config.getBackendName()).isEqualTo("memory");
|
||||
assertThat(config.getAppName()).isEqualTo("support");
|
||||
assertThat(config.getAppLabel()).isEqualTo("Support");
|
||||
assertThat(config.getTableMetaDataCustomizer()).isNotNull();
|
||||
HelpQBitProducer producer = new HelpQBitProducer();
|
||||
assertThat(producer.isEnabled()).isFalse();
|
||||
producer.withQBitConfig(config).withNamespace("host");
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("host");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-help");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Coverage Uses Core Totals And Missing Keys With Default Values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coverageUsesCoreTotalsAndMissingKeysWithDefaultValues() throws Exception
|
||||
{
|
||||
new InsertAction().execute(new InsertInput("helpContent").withRecord(help(1, "table:person;field:name")));
|
||||
TableData totals = widget(HelpWidgets.SUMMARY);
|
||||
assertThat(totals.getRows()).hasSize(11);
|
||||
var fields = totals.getRows().stream().filter(row -> row.get("kind").equals("Fields")).findFirst().orElseThrow();
|
||||
assertThat(fields.get("withHelp")).isEqualTo(1L);
|
||||
var core = new HelpCoverageAction().execute(new HelpCoverageInput()).getEntries().stream().filter(entry -> !entry.getHidden()).toList();
|
||||
var overall = totals.getRows().getLast();
|
||||
assertThat(overall.get("total")).isEqualTo(core.size());
|
||||
assertThat(overall.get("withHelp")).isEqualTo(1L);
|
||||
TableData missing = widget(HelpWidgets.MISSING);
|
||||
assertThat(missing.getRows()).noneSatisfy(row -> assertThat(row.get("key")).isIn("table:person;field:name", "table:person;field:hidden"));
|
||||
var row = missing.getRows().stream().filter(r -> "table:person;slot:help".equals(r.get("key"))).findFirst().orElseThrow();
|
||||
String url = URLDecoder.decode(href(row, "action"), StandardCharsets.UTF_8);
|
||||
assertThat(url).contains("/app/helpContent/create#defaultValues=", "\"key\":\"table:person;slot:help\"", "\"role\":\"ALL_SCREENS\"");
|
||||
assertThat(missing.getLabel()).contains("of " + (core.size() - 1) + " targets missing help");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden Targets Are Opt In And Missing List Is Bounded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hiddenTargetsAreOptInAndMissingListIsBounded() throws Exception
|
||||
{
|
||||
for(int i = 0; i < 250; i++)
|
||||
{
|
||||
instance.getTable("person").withField(new QFieldMetaData("extra" + i, QFieldType.STRING));
|
||||
}
|
||||
instance.getWidget(HelpWidgets.MISSING).withDefaultValue("includeHidden", true);
|
||||
assertThat(widget(HelpWidgets.MISSING).getRows()).hasSize(200);
|
||||
instance.getWidget(HelpWidgets.SUMMARY).withDefaultValue("includeHidden", true);
|
||||
int withHidden = (int) widget(HelpWidgets.SUMMARY).getRows().getLast().get("total");
|
||||
instance.getWidget(HelpWidgets.SUMMARY).withDefaultValue("includeHidden", false);
|
||||
assertThat((int) widget(HelpWidgets.SUMMARY).getRows().getLast().get("total")).isLessThan(withHidden);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent Changes Are Ordered And Linked.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentChangesAreOrderedAndLinked() throws Exception
|
||||
{
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecords(List.of(
|
||||
help(1, "table:person;field:name"), help(2, "table:person;slot:help").withValue("modifyDate", Instant.parse("2026-10-14T10:00:00Z")))));
|
||||
TableData recent = widget(HelpWidgets.RECENT);
|
||||
assertThat(recent.getRows()).hasSize(2);
|
||||
assertThat(recent.getRows().getFirst().get("key")).isEqualTo("table:person;slot:help");
|
||||
assertThat(href(recent.getRows().getFirst(), "record")).isEqualTo("/app/helpContent/2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview Carries Rendered Help And View Fields Without Changing Shared Metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void previewCarriesRenderedHelpAndViewFieldsWithoutChangingSharedMetadata() throws Exception
|
||||
{
|
||||
QRecord record = help(1, "table:person;field:name").withValue("role", "EDIT_SCREEN")
|
||||
.withValue("permissionName", "finance.view").withValue("clientChannel", "web");
|
||||
new InsertAction().execute(new InsertInput("helpContent").withRecord(record));
|
||||
RunProcessOutput review = review(HelpProcesses.PREVIEW, "1");
|
||||
assertThat(review.getException()).isEmpty();
|
||||
assertThat(review.getProcessMetaDataAdjustment()).isNotNull();
|
||||
var adjusted = review.getProcessMetaDataAdjustment().getUpdatedFrontendStepList();
|
||||
assertThat(adjusted).hasSize(2);
|
||||
QHelpContent content = adjusted.getFirst().getHelpContents().getFirst();
|
||||
assertThat(content.getContent()).isEqualTo("Read **this** first.");
|
||||
assertThat(content.getContentAsHtml()).contains("<strong>this</strong>");
|
||||
assertThat(content.getSummary()).isEqualTo("Short help 1");
|
||||
assertThat(content.getFormat()).isEqualTo(HelpFormat.MARKDOWN);
|
||||
assertThat(content.getRoles()).containsExactly(QHelpRole.ALL_SCREENS);
|
||||
assertThat(review.getValues()).containsEntry("role", "EDIT_SCREEN").containsEntry("permissionName", "finance.view").containsEntry("clientChannel", "web");
|
||||
assertThat(adjusted.getFirst().getViewFields()).extracting(QFieldMetaData::getName)
|
||||
.containsExactly("summary", "key", "role", "format", "permissionName", "clientChannel");
|
||||
assertThat(instance.getProcess(HelpProcesses.PREVIEW).getFrontendStep("review").getHelpContents()).isNullOrEmpty();
|
||||
var lines = ProcessRunner.run(HelpProcesses.PREVIEW, 1);
|
||||
assertThat(lines.review()).contains("1 help entry previewed");
|
||||
assertThat(lines.result()).contains("1 help entry previewed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start a real process and stop at its frontend Review step.
|
||||
*******************************************************************************/
|
||||
private static RunProcessOutput review(String name, String ids) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(name);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue("recordIds", ids);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
return (new RunProcessAction().execute(input));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview Rejects Multiple Records.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void previewRejectsMultipleRecords() throws Exception
|
||||
{
|
||||
new InsertAction().execute(new InsertInput("helpContent").withRecords(List.of(help(1, "table:person;field:name"), help(2, "table:person;slot:help"))));
|
||||
assertThatThrownBy(() -> review(HelpProcesses.PREVIEW, "1,2")).hasMessageContaining("exactly one record");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check Keys Reports Good Bad Hidden And Wildcard Keys.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void checkKeysReportsGoodBadHiddenAndWildcardKeys() throws Exception
|
||||
{
|
||||
List<QRecord> rows = List.of(help(1, "table:person;field:name"), help(2, "table:gone;field:x"),
|
||||
help(3, "process:*preview;step:review"), help(4, "table:person;field:hidden"));
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecords(rows));
|
||||
var lines = ProcessRunner.run(HelpProcesses.CHECK, 1, 2, 3, 4);
|
||||
assertThat(lines.review()).contains("3 keys resolve", "1 keys do not match any object", "table:gone;field:x");
|
||||
assertThat(lines.result()).contains("3 keys resolve", "1 keys do not match any object", "table:gone;field:x");
|
||||
assertThat(new QueryAction().execute(new QueryInput("helpContent")).getRecords()).hasSize(4);
|
||||
assertThat(HelpProcesses.resolves(null, Set.of())).isFalse();
|
||||
assertThat(HelpProcesses.resolves("process:*;step:review", HelpProcesses.keys())).isFalse();
|
||||
assertThat(HelpProcesses.resolves("process:*missing;step:review", HelpProcesses.keys())).isFalse();
|
||||
assertThat(HelpProcesses.resolves("process:*preview;step:absent", HelpProcesses.keys())).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Viewer Can Read But Cannot Author Or Run Coverage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void viewerCanReadButCannotAuthorOrRunCoverage() throws Exception
|
||||
{
|
||||
new InsertAction().execute(new InsertInput("helpContent").withRecord(help(1, "table:person;field:name")));
|
||||
as("help.read", "help.hasAccess");
|
||||
QContext.getQSession().withClientChannel("assistant");
|
||||
assertThat(new QueryAction().execute(new QueryInput("helpContent")).getRecords()).hasSize(1);
|
||||
assertThat(widget(HelpWidgets.RECENT).getRows()).hasSize(1);
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput("helpContent").withRecord(help(2, "table:person;slot:help"))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput("helpContent").withRecord(help(1, "table:person;field:name"))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput("helpContent").withPrimaryKeys(List.of(1))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> widget(HelpWidgets.SUMMARY)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> widget(HelpWidgets.MISSING)).isInstanceOf(QPermissionDeniedException.class);
|
||||
for(String process : List.of(HelpProcesses.PREVIEW, "helpBundleSync", HelpProcesses.CHECK))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new QueryInput(), process)).isFalse();
|
||||
assertThatThrownBy(() -> ProcessRunner.run(process)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new HelpCoverageAction().execute(new HelpCoverageInput())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> ProcessRunner.run("helpBundleSync")).isInstanceOf(QPermissionDeniedException.class);
|
||||
PermissionsHelper.checkAppPermissionThrowing(new QueryInput(), "help");
|
||||
as();
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput("helpContent"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent Changes Are Bounded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentChangesAreBounded() throws Exception
|
||||
{
|
||||
for(int i = 1; i <= 25; i++)
|
||||
{
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecord(help(i, "table:person;field:name")));
|
||||
}
|
||||
TableData recent = widget(HelpWidgets.RECENT);
|
||||
assertThat(recent.getRows()).hasSize(20);
|
||||
assertThat(href(recent.getRows().getFirst(), "record")).isEqualTo("/app/helpContent/25");
|
||||
assertThat(href(recent.getRows().getLast(), "record")).isEqualTo("/app/helpContent/6");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview Rejects Empty Selection And Supports Plain Text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void previewRejectsEmptySelectionAndSupportsPlainText() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> review(HelpProcesses.PREVIEW, "")).hasMessageContaining("No records were selected");
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecord(help(1, "table:person;field:name").withValue("format", "TEXT")));
|
||||
QHelpContent help = review(HelpProcesses.PREVIEW, "1").getUpdatedFrontendStepList().getFirst().getHelpContents().getFirst();
|
||||
assertThat(help.getFormat()).isEqualTo(HelpFormat.TEXT);
|
||||
assertThat(help.getContent()).isEqualTo("Read **this** first.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Viewer Metadata Shows Only Read Permission.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void viewerMetadataShowsOnlyReadPermission() throws Exception
|
||||
{
|
||||
as("help.read", "help.hasAccess");
|
||||
TableMetaDataInput input = new TableMetaDataInput();
|
||||
input.setTableName("helpContent");
|
||||
var table = new TableMetaDataAction().execute(input).getTable();
|
||||
assertThat(table.getReadPermission()).isTrue();
|
||||
assertThat(table.getInsertPermission()).isFalse();
|
||||
assertThat(table.getEditPermission()).isFalse();
|
||||
assertThat(table.getDeletePermission()).isFalse();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Author Metadata Shows All Table Permissions And Authoring Actions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void authorMetadataShowsAllPermissions() throws Exception
|
||||
{
|
||||
TableMetaDataInput input = new TableMetaDataInput();
|
||||
input.setTableName("helpContent");
|
||||
var table = new TableMetaDataAction().execute(input).getTable();
|
||||
assertThat(table.getReadPermission()).isTrue();
|
||||
assertThat(table.getInsertPermission()).isTrue();
|
||||
assertThat(table.getEditPermission()).isTrue();
|
||||
assertThat(table.getDeletePermission()).isTrue();
|
||||
for(String process : List.of(HelpProcesses.PREVIEW, "helpBundleSync", HelpProcesses.CHECK))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasProcessPermission(input, process)).isTrue();
|
||||
}
|
||||
for(String widget : List.of(HelpWidgets.SUMMARY, HelpWidgets.MISSING, HelpWidgets.RECENT))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(input, widget)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host Rules Are Preserved Only When Opting Out On An Existing Table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionRulesRespectTableOwnership() throws Exception
|
||||
{
|
||||
HelpQBitConfig config = new HelpQBitConfig().withBackendName("memory");
|
||||
assertThat(config.getApplyPermissionRulesToHelpContent()).isTrue();
|
||||
config.setApplyPermissionRulesToHelpContent(false);
|
||||
assertThat(config.getApplyPermissionRulesToHelpContent()).isFalse();
|
||||
QInstance host = base();
|
||||
new HelpQBitProducer().withQBitConfig(config).produce(host);
|
||||
assertThat(host.getTable("helpContent").getPermissionRules().getLevel())
|
||||
.isEqualTo(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
|
||||
for(boolean applyRules : List.of(false, true))
|
||||
{
|
||||
host = base();
|
||||
new HelpContentMetaDataProvider().defineAll(host, "memory", null);
|
||||
QPermissionRules hostRules = new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)
|
||||
.withPermissionBaseName("hostHelp");
|
||||
host.getTable("helpContent").withPermissionRules(hostRules);
|
||||
new HelpQBitProducer().withQBitConfig(new HelpQBitConfig().withApplyPermissionRulesToHelpContent(applyRules)).produce(host);
|
||||
QPermissionRules actual = host.getTable("helpContent").getPermissionRules();
|
||||
if(applyRules)
|
||||
{
|
||||
assertThat(actual.getLevel()).isEqualTo(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
|
||||
assertThat(actual.getPermissionBaseName()).isEqualTo("help");
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(actual).isSameAs(hostRules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Customizer Cannot Rename Core Storage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void customizerCannotRenameCoreStorage()
|
||||
{
|
||||
assertThatThrownBy(() -> new HelpQBitProducer().withQBitConfig(new HelpQBitConfig().withBackendName("memory")
|
||||
.withTableMetaDataCustomizer((q, t) -> t.withName("renamed"))).produce(base()))
|
||||
.hasMessageContaining("without renaming");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review status filtering, literal diff content, links, and permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleReviewWidgetsAndRecordDiff() throws Exception
|
||||
{
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecords(List.of(
|
||||
help(1, "table:person;field:name").withValue("bundleId", "test").withValue("bundleVersion", "1")
|
||||
.withValue("upstreamStatus", "UPSTREAM_CHANGED").withValue("upstreamSummary", "New summary")
|
||||
.withValue("upstreamContent", "<script>plain text</script>"),
|
||||
help(2, "table:gone;slot:help").withValue("upstreamStatus", "RETIRED"),
|
||||
help(3, "table:person;slot:help").withValue("upstreamStatus", "CURRENT"),
|
||||
help(4, "process:*preview;step:review"), help(5, "table:person;field:hidden"))));
|
||||
var changed = widget(HelpWidgets.UPSTREAM).getRows();
|
||||
assertThat(changed).hasSize(1);
|
||||
assertThat(changed.getFirst()).containsEntry("summary", "Short help 1").containsEntry("upstreamSummary", "New summary")
|
||||
.containsEntry("changes", "summary changed, content changed").containsEntry("bundleId", "test");
|
||||
assertThat(href(changed.getFirst(), "record")).isEqualTo("/app/helpContent/1");
|
||||
assertThat(widget(HelpWidgets.RETIRED).getRows()).singleElement().satisfies(row -> assertThat(row.get("key")).isEqualTo("table:gone;slot:help"));
|
||||
assertThat(widget(HelpWidgets.UNRESOLVED).getRows()).hasSize(1);
|
||||
var input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(HelpWidgets.DIFF)).withUrlParams(Map.of("id", "1"));
|
||||
var diff = (TableData) new HelpWidgets().render(input).getWidgetData();
|
||||
assertThat(diff.getColumns()).hasSize(2);
|
||||
assertThat(diff.getRows()).containsExactly(Map.of("current", "Summary:\nShort help 1", "upstream", "Summary:\nNew summary"),
|
||||
Map.of("current", "Content:\nRead **this** first.", "upstream", "Content:\n<script>plain text</script>"));
|
||||
for(String id : List.of("2", "3", "999", ""))
|
||||
{
|
||||
var hidden = (com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData) new HelpWidgets().render(input.withUrlParams(Map.of("id", id))).getWidgetData();
|
||||
assertThat(hidden.getHideWidget()).isTrue();
|
||||
}
|
||||
assertThat(instance.getTable("helpContent").getSections()).anySatisfy(section ->
|
||||
{
|
||||
assertThat(section.getWidgetName()).isEqualTo(HelpWidgets.DIFF);
|
||||
assertThat(section.getGridColumns()).isEqualTo(12);
|
||||
});
|
||||
as("help.read", "help.hasAccess");
|
||||
for(String name : List.of(HelpWidgets.UPSTREAM, HelpWidgets.RETIRED, HelpWidgets.UNRESOLVED, HelpWidgets.BUNDLES, HelpWidgets.DIFF))
|
||||
{
|
||||
assertThatThrownBy(() -> widget(name)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
for(String name : List.of("helpBundleSync", "helpAcceptUpstream", "helpKeepMine"))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new QueryInput(), name)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bundle coverage separates versions, pending rows and unresolved targets.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleCountsSeparateVersionsAndPendingRows() throws Exception
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles.register(instance, "help/review-test.yaml");
|
||||
var registry = com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles.of(instance);
|
||||
var bundle = registry.getBundles().getLast();
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecords(List.of(
|
||||
help(1, "table:person;slot:help").withValue("bundleId", bundle.getId()).withValue("bundleVersion", bundle.getVersion()).withValue("upstreamStatus", "CURRENT"),
|
||||
help(2, "table:person;field:name").withValue("bundleId", bundle.getId()).withValue("bundleVersion", bundle.getVersion()).withValue("upstreamStatus", "UPSTREAM_CHANGED"),
|
||||
help(3, "table:person;field:name").withValue("bundleId", bundle.getId()).withValue("bundleVersion", "old").withValue("upstreamStatus", "CURRENT"),
|
||||
help(4, "table:gone;slot:help").withValue("bundleId", bundle.getId()).withValue("bundleVersion", bundle.getVersion()).withValue("upstreamStatus", "CURRENT"))));
|
||||
assertThat(widget(HelpWidgets.BUNDLES).getRows()).hasSize(2);
|
||||
assertThat(widget(HelpWidgets.BUNDLES).getRows().getFirst().get("applied")).isEqualTo(0L);
|
||||
assertThat(widget(HelpWidgets.BUNDLES).getRows().stream().filter(row -> bundle.getId().equals(row.get("bundle"))).toList()).singleElement().satisfies(row ->
|
||||
assertThat(row).containsEntry("bundle", bundle.getId()).containsEntry("version", bundle.getVersion())
|
||||
.containsEntry("entries", bundle.getEntries().size()).containsEntry("applied", 1L));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty tables and legacy null keys remain safe to inspect.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyReviewAndSummaryOnlyChange() throws Exception
|
||||
{
|
||||
for(String name : List.of(HelpWidgets.UPSTREAM, HelpWidgets.RETIRED, HelpWidgets.UNRESOLVED))
|
||||
{
|
||||
assertThat(widget(name).getRows()).isEmpty();
|
||||
}
|
||||
new MemoryInsertAction().execute(new InsertInput("helpContent").withRecords(List.of(
|
||||
help(1, "table:person;slot:custom").withValue("upstreamStatus", "UPSTREAM_CHANGED")
|
||||
.withValue("upstreamSummary", "New").withValue("upstreamContent", "Read **this** first."),
|
||||
help(2, null))));
|
||||
assertThat(widget(HelpWidgets.UPSTREAM).getRows()).singleElement().satisfies(row -> assertThat(row.get("changes")).isEqualTo("summary changed"));
|
||||
assertThat(widget(HelpWidgets.UNRESOLVED).getRows()).singleElement().satisfies(row -> assertThat(row.get("key")).isNull());
|
||||
assertThat(((com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData) new HelpWidgets().render(
|
||||
new RenderWidgetInput().withWidgetMetaData(instance.getWidget(HelpWidgets.DIFF))).getWidgetData()).getHideWidget()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core sync has a read-only review and populates persisted bundle counts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreSyncSuppliesReviewAndResultAndPopulatesBundleCoverage() throws Exception
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles.register(instance, "help/review-test.yaml");
|
||||
var review = review("helpBundleSync", "");
|
||||
assertThat(review.getException()).isEmpty();
|
||||
assertThat(new QueryAction().execute(new QueryInput("helpContent")).getRecords()).isEmpty();
|
||||
var lines = ProcessRunner.run("helpBundleSync");
|
||||
assertThat(lines.review()).isNotEmpty();
|
||||
assertThat(lines.result()).isNotEmpty();
|
||||
assertThat(widget(HelpWidgets.BUNDLES).getRows().stream().filter(row -> "review-test".equals(row.get("bundle"))).toList())
|
||||
.singleElement().satisfies(row -> assertThat(row).containsEntry("entries", 1).containsEntry("applied", 1L));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.help;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs a streamed-ETL table or record action the way the UI does: the
|
||||
** review screen, then the result.
|
||||
*******************************************************************************/
|
||||
public final class ProcessRunner
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Review and result lines, as shown.
|
||||
*******************************************************************************/
|
||||
public record Lines(List<String> review, List<String> result)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ProcessRunner()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run on some record ids.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Lines run(String process, Serializable... ids) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(process);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue("recordIds", Arrays.stream(ids).map(String::valueOf).collect(Collectors.joining(",")));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
RunProcessOutput review = new RunProcessAction().execute(input);
|
||||
if(review.getException().isPresent())
|
||||
{
|
||||
throw (review.getException().get());
|
||||
}
|
||||
List<ProcessSummaryLineInterface> reviewLines = (List<ProcessSummaryLineInterface>) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
|
||||
RunProcessInput go = new RunProcessInput();
|
||||
go.setProcessName(process);
|
||||
go.setProcessUUID(review.getProcessUUID());
|
||||
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
|
||||
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput result = new RunProcessAction().execute(go);
|
||||
if(result.getException().isPresent())
|
||||
{
|
||||
throw (result.getException().get());
|
||||
}
|
||||
List<ProcessSummaryLineInterface> resultLines = (List<ProcessSummaryLineInterface>) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
return (new Lines(text(reviewLines, false), text(resultLines, true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lines as text.
|
||||
*******************************************************************************/
|
||||
private static List<String> text(List<ProcessSummaryLineInterface> lines, boolean past)
|
||||
{
|
||||
return (lines == null ? List.of() : lines.stream().map(line ->
|
||||
{
|
||||
if(line instanceof ProcessSummaryLine summary)
|
||||
{
|
||||
summary.pickMessage(past);
|
||||
return ((summary.getCount() == null ? "" : summary.getCount() + " ") + summary.getMessage());
|
||||
}
|
||||
return (String.valueOf(line));
|
||||
}).toList());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
bundle: review-test
|
||||
version: "1"
|
||||
layer: HOST
|
||||
entries:
|
||||
- key: table:person;field:name
|
||||
summary: A person name.
|
||||
content: Use the full name.
|
||||
Reference in new issue
Block a user