diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c56830..c73a6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9bb0d40..0c5d597 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CONTINGENT-POSSIBLE-VALUES.md b/docs/CONTINGENT-POSSIBLE-VALUES.md new file mode 100644 index 0000000..697933b --- /dev/null +++ b/docs/CONTINGENT-POSSIBLE-VALUES.md @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index bd5aaad..032748d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -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 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)) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/values/FeatureScopeNamePossibleValueProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/values/FeatureScopeNamePossibleValueProvider.java new file mode 100644 index 0000000..e332789 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/values/FeatureScopeNamePossibleValueProvider.java @@ -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 +{ + /******************************************************************************* + ** Without scope context, prefer an app label, then a table label. + *******************************************************************************/ + @Override + public QPossibleValue 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> search(SearchPossibleValueSourceInput input) + { + List> 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/values/PossibleValueSearch.java b/src/main/java/com/kingsrook/qbits/collaboration/values/PossibleValueSearch.java new file mode 100644 index 0000000..c40b8f9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/values/PossibleValueSearch.java @@ -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 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))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/values/RecordFieldNamePossibleValueProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/values/RecordFieldNamePossibleValueProvider.java new file mode 100644 index 0000000..35c5e03 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/values/RecordFieldNamePossibleValueProvider.java @@ -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 +{ + /******************************************************************************* + ** A bare field name cannot be labeled without its table context. + *******************************************************************************/ + @Override + public QPossibleValue 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> search(SearchPossibleValueSourceInput input) throws QException + { + List> 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 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)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/DependentPossibleValuesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/DependentPossibleValuesTest.java new file mode 100644 index 0000000..b9b5b0b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/DependentPossibleValuesTest.java @@ -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> search(String table, String field, Map values, String term, List 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()); + } +}