Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 9ba6e41377 feat: contingent possible values for comment parent, feature scope name and comment field anchor
parentCommentId lists only its thread (threadId or id = ${input.threadId});
feature settings' scopeName is listed by scopeType (apps or eligible
tables) through FeatureScopeNamePossibleValueProvider; a comment's
fieldName lists the visible fields of its tableName through the reusable
RecordFieldNamePossibleValueProvider. TODOs mark the core dependsOn
switch. One test per pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:18:24 -05:00
james.maes 38a95aeee0 Merge pull request #11 from QRun-IO/fix/object-storage-api-only
fix(attachments): use only qbit-object-storage's api package
2026-10-13 20:01:33 -05:00
james.maesandClaude Opus 5.5 88bff675e9 fix(attachments): use only qbit-object-storage's api package
Stop reading the bucket cap through StorageBucket.config() (the non-api
StorageBucketConfig, flagged by the qbit-bom guardrails, G1-arch).
Collaboration enforces its per-table cap; object storage still enforces
the bucket cap at put time. An api accessor is requested upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:58:41 -05:00
james.maes 0b19f956a2 Merge pull request #9 from QRun-IO/feature/sharing-tasks
feat: sharing with access requests, tasks, owner/assignee and My work
2026-10-13 19:09:31 -05:00
james.maes 003384abd4 Merge pull request #5 from QRun-IO/feature/tags
feat: tags with approval modes and bulk actions
2026-10-13 17:40:50 -05:00
james.maes 76e4944919 Merge pull request #4 from QRun-IO/feature/comments-follow-timeline
feat: comments with mentions, follow and activity timeline
2026-10-13 16:44:35 -05:00
james.maes 4898e78c5d Merge pull request #3 from QRun-IO/feature/attachments
feat: record attachments through qbit-object-storage
2026-10-13 16:15:24 -05:00
james.maes 1e750b9dc6 Merge pull request #2 from QRun-IO/feature/favorites-recents
feat: favorites and recently viewed
2026-10-13 15:57:29 -05:00
9 changed files with 442 additions and 5 deletions

No files matched your search

+2
View File
@@ -4,6 +4,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
- Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests.
### Added
- Phase-h links, share previews, recipient-safe notifications and SHARED followers; access requests with requester/admin locks, duplicate OPEN handling, optional permission-holder directory and streamed Mark granted/Decline outcomes.
+4
View File
@@ -4,6 +4,10 @@ Record collaboration for QQQ: attachments, tags, custom fields, comments with me
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
## Dependent fields
Comment parents list the selected thread's root and replies. Feature-setting scope names follow `scopeType` (apps, eligible tables, or `instance`). Comment field anchors follow `tableName` and offer non-hidden fields visible to the session. These use RC.5 filter variables and custom providers receiving `otherValues`; automatic dependency refresh awaits core `dependsOn`. See [dependent-field details](docs/CONTINGENT-POSSIBLE-VALUES.md).
## Requirements
- Java 21.
+9
View File
@@ -0,0 +1,9 @@
# Dependent possible values
QQQ 4.1.0-RC.5 has no `dependsOn` field property. Each pair has a TODO to add it when core ships it; automatic refresh and clearing stale selections remain frontend responsibilities.
- `collaboration_comment.parentCommentId` reuses the `collaboration_comment` TABLE source. Its field filter is `threadId EQUALS ${input.threadId} OR id EQUALS ${input.threadId}`: roots have null `threadId`, replies carry the root ID. The OR is nested inside an outer AND because core appends search criteria to the supplied filter. This lists the root and replies of just that thread; comment posting still normalizes a selected reply to its root.
- `collaboration_feature_setting.scopeName` uses `collaboration_scope_name`, a CUSTOM source driven by `otherValues.scopeType`. APP offers app names and labels. TABLE excludes `collaboration_*`, hidden tables and tables without a valid primary-key field (RC.5 metadata has one primary-key field). INSTANCE offers `instance`, since scopeName is required but the current TAGS settings reader ignores it for INSTANCE. General runtime override contracts remain deferred. Bare-ID translation prefers an app label before a table label if their names collide; searches use the selected scope's label.
- `collaboration_comment.fieldName` uses reusable CUSTOM source `collaboration_record_field_name`. It resolves `otherValues.tableName`, or reads a comment via `commentId`/`id` when tableName is absent. Other forms should supply tableName (or explicit commentId); bare id fallback is for comment forms. Fields come from permission-enforced personalized metadata, excluding hidden fields, and display `Label (name)`. Bare-ID translation retains the name because it has no table context.
The table possible-values middleware clones the field filter and calls `interpretValues` with the `input` map and FORM use case before passing it as `defaultQueryFilter` to `SearchPossibleValueSourceAction`. Tests mirror that boundary and pass `otherValues` to the core action. CUSTOM providers receive that map directly; searches honor explicit IDs first, otherwise case-insensitive name/label substring matching.
@@ -45,11 +45,14 @@ public class AttachmentStorage
}
/*******************************************************************************
** Enforce the bucket's cap as well as collaboration's configured cap.
** The bucket's own upload cap, when object storage exposes it through its api
** package. It does not yet (requested from qbit-object-storage), so this
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
** and object storage still enforces the bucket's cap when the object is put.
*******************************************************************************/
public long maxBytes(String name)
{
return (bucket(name).config().getMaxUploadBytes());
return (Long.MAX_VALUE);
}
/*******************************************************************************
@@ -29,8 +29,13 @@ import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.attachments.AttachmentPreviewCustomizer;
import com.kingsrook.qbits.collaboration.attachments.AttachmentSecurity;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.values.FeatureScopeNamePossibleValueProvider;
import com.kingsrook.qbits.collaboration.values.RecordFieldNamePossibleValueProvider;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter.BooleanOperator;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
@@ -58,6 +63,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
*******************************************************************************/
public final class CollaborationTables
{
public static final String SCOPE_NAME = "collaboration_scope_name";
public static final String RECORD_FIELD_NAME = "collaboration_record_field_name";
public static final String SHARE = "collaboration_share";
public static final String ACCESS_REQUEST = "collaboration_access_request";
public static final String TASK = "collaboration_task";
@@ -97,6 +104,10 @@ public final class CollaborationTables
public static List<QPossibleValueSource> possibleValueSources()
{
return (List.of(
new QPossibleValueSource().withName(SCOPE_NAME).withType(QPossibleValueSourceType.CUSTOM)
.withCustomCodeReference(new QCodeReference(FeatureScopeNamePossibleValueProvider.class)),
new QPossibleValueSource().withName(RECORD_FIELD_NAME).withType(QPossibleValueSourceType.CUSTOM)
.withCustomCodeReference(new QCodeReference(RecordFieldNamePossibleValueProvider.class)),
QPossibleValueSource.newForTable(SHARE), QPossibleValueSource.newForTable(ACCESS_REQUEST),
QPossibleValueSource.newForTable(TASK), QPossibleValueSource.newForTable(RECORD_ASSIGNMENT),
enumSource("collaboration_access_request_status", "OPEN", "GRANTED", "DECLINED", "WITHDRAWN"),
@@ -138,7 +149,8 @@ public final class CollaborationTables
QTableMetaData settings = base(config, FEATURE_SETTING, "Feature Settings", "settings")
.withRecordLabelFormatAndFields("%s %s: %s", "scopeType", "scopeName", "feature")
.withField(string("scopeType", "Scope Type", 20).withIsRequired(true).withPossibleValueSourceName(SCOPE_TYPE))
.withField(string("scopeName", "Scope Name", 128).withIsRequired(true))
// TODO: add .withDependsOn("scopeType") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
.withField(string("scopeName", "Scope Name", 128).withIsRequired(true).withPossibleValueSourceName(SCOPE_NAME))
.withField(string("feature", "Feature", 32).withIsRequired(true).withPossibleValueSourceName(FEATURE))
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enabled").withIsRequired(true).withDefaultValue(false))
.withField(new QFieldMetaData("settings", QFieldType.TEXT).withLabel("Settings (JSON)"))
@@ -283,9 +295,16 @@ public final class CollaborationTables
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withSearchFields(List.of("body"))
.withField(new QFieldMetaData("threadId", QFieldType.INTEGER).withPossibleValueSourceName(COMMENT))
.withField(new QFieldMetaData("parentCommentId", QFieldType.INTEGER).withPossibleValueSourceName(COMMENT))
// Roots have null threadId; include the root by id as well as its replies by threadId.
// Keep OR nested: core appends its search criteria to the outer AND filter.
// TODO: add .withDependsOn("threadId") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
.withField(new QFieldMetaData("parentCommentId", QFieldType.INTEGER).withPossibleValueSourceName(COMMENT)
.withPossibleValueSourceFilter(new QQueryFilter().withSubFilter(new QQueryFilter().withBooleanOperator(BooleanOperator.OR)
.withCriteria("threadId", QCriteriaOperator.EQUALS, "${input.threadId}")
.withCriteria("id", QCriteriaOperator.EQUALS, "${input.threadId}"))))
.withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000))
.withField(string("fieldName", "Field", 128))
// TODO: add .withDependsOn("tableName") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
.withField(string("fieldName", "Field", 128).withPossibleValueSourceName(RECORD_FIELD_NAME))
.withField(string("authorUserId", "Author", 255).withIsRequired(true))
.withField(new QFieldMetaData("lastActivityAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME))
@@ -0,0 +1,86 @@
/*
* 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.collaboration.values;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** App and eligible table names for scoped feature settings.
*******************************************************************************/
public class FeatureScopeNamePossibleValueProvider implements QCustomPossibleValueProvider<String>
{
/*******************************************************************************
** Without scope context, prefer an app label, then a table label.
*******************************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable idValue)
{
String name = ValueUtils.getValueAsString(idValue);
if(name == null)
{
return (null);
}
var instance = QContext.getQInstance();
var app = instance.getApp(name);
var table = instance.getTable(name);
String label = app != null ? app.getLabel() : table != null ? table.getLabel() : name;
return (new QPossibleValue<>(name, label == null ? name : label));
}
/*******************************************************************************
** INSTANCE ignores scopeName when reading overrides; use a canonical required value.
*******************************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input)
{
List<QPossibleValue<String>> values = new ArrayList<>();
String scope = input.getOtherValues() == null ? null : ValueUtils.getValueAsString(input.getOtherValues().get("scopeType"));
var instance = QContext.getQInstance();
if("APP".equals(scope))
{
instance.getApps().values().forEach(app -> values.add(new QPossibleValue<>(app.getName(), app.getLabel() == null ? app.getName() : app.getLabel())));
}
else if("TABLE".equals(scope))
{
instance.getTables().values().stream()
.filter(table -> !table.getName().startsWith("collaboration_") && !table.getIsHidden()
&& table.getPrimaryKeyField() != null && table.getFields().containsKey(table.getPrimaryKeyField()))
.forEach(table -> values.add(new QPossibleValue<>(table.getName(), table.getLabel() == null ? table.getName() : table.getLabel())));
}
else if("INSTANCE".equals(scope))
{
values.add(new QPossibleValue<>("instance", "instance"));
}
return (values.stream().filter(value -> PossibleValueSearch.matches(input, value)).toList());
}
}
@@ -0,0 +1,58 @@
/*
* 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.collaboration.values;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Shared matching semantics for metadata-backed possible values.
*******************************************************************************/
final class PossibleValueSearch
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PossibleValueSearch()
{
}
/*******************************************************************************
** Explicit ids take precedence; otherwise match label or name, ignoring case.
*******************************************************************************/
static boolean matches(SearchPossibleValueSourceInput input, QPossibleValue<String> value)
{
if(input.getIdList() != null && !input.getIdList().isEmpty())
{
return (input.getIdList().stream().anyMatch(id -> value.getId().equals(ValueUtils.getValueAsString(id))));
}
String term = input.getSearchTerm();
return (term == null || term.isBlank() || value.getId().toLowerCase(Locale.ROOT).contains(term.toLowerCase(Locale.ROOT))
|| value.getLabel().toLowerCase(Locale.ROOT).contains(term.toLowerCase(Locale.ROOT)));
}
}
@@ -0,0 +1,117 @@
/*
* 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.collaboration.values;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Reusable field-name source for forms carrying tableName, with comment fallback.
*******************************************************************************/
public class RecordFieldNamePossibleValueProvider implements QCustomPossibleValueProvider<String>
{
/*******************************************************************************
** A bare field name cannot be labeled without its table context.
*******************************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable idValue)
{
String name = ValueUtils.getValueAsString(idValue);
return (name == null ? null : new QPossibleValue<>(name, name));
}
/*******************************************************************************
** Offer only non-hidden fields in the session's personalized metadata.
*******************************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
{
List<QPossibleValue<String>> values = new ArrayList<>();
QTableMetaData table = resolveTable(input);
if(table != null)
{
for(var field : table.getFields().values())
{
if(!field.getIsHidden())
{
String label = field.getLabel() == null ? field.getName() : field.getLabel();
QPossibleValue<String> value = new QPossibleValue<>(field.getName(), label + " (" + field.getName() + ")");
if(PossibleValueSearch.matches(input, value))
{
values.add(value);
}
}
}
}
return (values);
}
/*******************************************************************************
** Explicit tableName works for any form. commentId (or id on a comment form)
** supplies a persisted fallback through the current session's normal read path.
*******************************************************************************/
private QTableMetaData resolveTable(SearchPossibleValueSourceInput input) throws QException
{
var otherValues = input.getOtherValues();
if(otherValues == null)
{
return (null);
}
String tableName = ValueUtils.getValueAsString(otherValues.get("tableName"));
Serializable commentId = otherValues.getOrDefault("commentId", otherValues.get("id"));
if((tableName == null || tableName.isBlank()) && commentId != null)
{
GetInput get = new GetInput(CollaborationTables.COMMENT).withPrimaryKey(commentId);
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var row = new GetAction().execute(get).getRecord();
tableName = row == null ? null : row.getValueString("tableName");
}
if(tableName == null || QContext.getQInstance().getTable(tableName) == null)
{
return (null);
}
TableMetaDataPersonalizerInput personalization = new TableMetaDataPersonalizerInput().withTableName(tableName).withInputSource(QInputSource.USER);
personalization.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (TableMetaDataPersonalizerAction.execute(personalization));
}
}
@@ -0,0 +1,139 @@
/*
* 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.collaboration;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.values.FeatureScopeNamePossibleValueProvider;
import com.kingsrook.qbits.collaboration.values.RecordFieldNamePossibleValueProvider;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
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.possiblevalues.PossibleValueSearchFilterUseCase;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Exercise registered dependent sources through the same core action as forms.
*******************************************************************************/
class DependentPossibleValuesTest extends PersonalServiceFixture
{
/*******************************************************************************
** Roots and replies from another thread must never leak into this picker.
*******************************************************************************/
@Test
void parentsAreRestrictedToTheSelectedThread() throws Exception
{
var comments = new CommentsService();
var root = comments.post("document", 1, "First root", null, null);
var reply = comments.post("document", 1, "First reply", root.id(), null);
var other = comments.post("document", 1, "Other root", null, null);
comments.post("document", 1, "Other reply", other.id(), null);
admin();
assertThat(search(CollaborationTables.COMMENT, "parentCommentId", Map.of("threadId", root.id()), null, null))
.extracting(value -> (Object) value.getId()).containsExactlyInAnyOrder(root.id(), reply.id());
assertThat(search(CollaborationTables.COMMENT, "parentCommentId", Map.of("threadId", other.id()), null, null))
.extracting(value -> (Object) value.getId()).doesNotContain(root.id(), reply.id());
assertThat(search(CollaborationTables.COMMENT, "parentCommentId", Map.of(), null, null)).isEmpty();
}
/*******************************************************************************
** Scope determines eligible metadata, and searches preserve names and labels.
*******************************************************************************/
@Test
void scopeNamesFollowScopeType() throws Exception
{
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales Office"));
instance.addTable(new QTableMetaData().withName("hidden").withIsHidden(true).withPrimaryKeyField("id"));
instance.addTable(new QTableMetaData().withName("no_key"));
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", Map.of("scopeType", "APP"), "OFFICE", null))
.extracting(QPossibleValue::getLabel).containsExactly("Sales Office");
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", Map.of("scopeType", "APP"), "no match", List.of("sales")))
.extracting(value -> (Object) value.getId()).containsExactly("sales");
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", Map.of("scopeType", "TABLE"), null, null))
.extracting(value -> (Object) value.getId()).containsExactly("document");
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", Map.of(), null, null)).isEmpty();
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", null, null, null)).isEmpty();
assertThat(search(CollaborationTables.FEATURE_SETTING, "scopeName", Map.of("scopeType", "INSTANCE"), null, null))
.extracting(value -> (Object) value.getId()).containsExactly("instance");
var provider = new FeatureScopeNamePossibleValueProvider();
assertThat(provider.getPossibleValue("sales").getLabel()).isEqualTo("Sales Office");
assertThat(provider.getPossibleValue("document").getLabel()).isEqualTo("Documents");
assertThat(provider.getPossibleValue("unknown").getLabel()).isEqualTo("unknown");
assertThat(provider.getPossibleValue(null)).isNull();
}
/*******************************************************************************
** Table fields are labeled and hidden fields are excluded; stored rows resolve too.
*******************************************************************************/
@Test
void fieldNamesFollowTableAndPersistedComment() throws Exception
{
instance.addTable(new QTableMetaData().withName("person").withLabel("People").withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID"))
.withField(new QFieldMetaData("name", QFieldType.STRING).withLabel("Full Name"))
.withField(new QFieldMetaData("secret", QFieldType.STRING).withIsHidden(true))
.withField(new QFieldMetaData("restricted", QFieldType.STRING).withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("tenant").withOverrideValues(List.of("b")))));
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of("tableName", "person"), null, null))
.extracting(QPossibleValue::getLabel).containsExactly("ID (id)", "Full Name (name)");
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of("tableName", "person"), "FULL", null))
.extracting(value -> (Object) value.getId()).containsExactly("name");
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of("tableName", "person"), "absent", List.of("id")))
.extracting(value -> (Object) value.getId()).containsExactly("id");
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of(), null, null)).isEmpty();
assertThat(search(CollaborationTables.COMMENT, "fieldName", null, null, null)).isEmpty();
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of("tableName", "unknown"), null, null)).isEmpty();
var root = new CommentsService().post("document", 1, "Root", null, null);
admin();
assertThat(search(CollaborationTables.COMMENT, "fieldName", Map.of("id", root.id()), null, null))
.extracting(value -> (Object) value.getId()).contains("title");
var provider = new RecordFieldNamePossibleValueProvider();
assertThat(provider.getPossibleValue("name").getLabel()).isEqualTo("name");
assertThat(provider.getPossibleValue(null)).isNull();
}
/*******************************************************************************
** Mirror TablePossibleValuesSpecV1: clone and interpret metadata, then search.
*******************************************************************************/
private List<QPossibleValue<?>> search(String table, String field, Map<String, Serializable> values, String term, List<Serializable> ids) throws Exception
{
var metadata = instance.getTable(table).getField(field);
var input = new SearchPossibleValueSourceInput().withPossibleValueSourceName(metadata.getPossibleValueSourceName())
.withOtherValues(values).withSearchTerm(term).withIdList(ids);
if(metadata.getPossibleValueSourceFilter() != null)
{
var filter = metadata.getPossibleValueSourceFilter().clone();
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
input.setDefaultQueryFilter(filter);
}
return (new SearchPossibleValueSourceAction().execute(input).getResults());
}
}