feat(slack): match configured channels by id and show their names (#15962)

This commit is contained in:
Nikolas Garza authored and GitHub committed 2026-10-13 19:08:56 +00:00
1 parent e37cb1d814
commit ea51d509dd
38 files changed
+877 -89

No files matched your search

+1 -1
View File
@@ -315,7 +315,7 @@ range:
|---|---|---|---|
| Confluence (`confluence/connector.py:ConfluenceConnector`) | `CheckpointedConnector`, `SlimConnector`, `SlimConnectorWithPermSync`, `CredentialsConnector`, `Resolver` | `ConfluenceCheckpoint` (`next_page_url`) | No `LoadConnector`/`PollConnector`; checkpointing is mandatory, not optional. `load_credentials` deliberately raises `NotImplementedError("Use set_credentials_provider with this connector.")`. Has a `source_operations.py` gateway (`ConfluenceSourceOperations`) and registers named capability checks. |
| Google Drive (`google_drive/connector.py:GoogleDriveConnector`) | `SlimConnector`, `SlimConnectorWithPermSync`, `CheckpointedConnectorWithPermSync`, `Resolver` | `GoogleDriveCheckpoint` (`google_drive/models.py`): a multi-stage state machine (`DriveRetrievalStage`: `START → OAUTH_FILES → USER_EMAILS → MY_DRIVE_FILES → DRIVE_IDS → SHARED_DRIVE_FILES → DONE`) plus a per-impersonated-user completion map. Not a simple cursor. | Splits source-API logic across `file_retrieval.py`, `doc_conversion.py`, `section_extraction.py` rather than a single `source_operations.py`. |
| Slack (`slack/connector.py:SlackConnector`) | `SlimConnectorWithPermSync`, `CredentialsConnector`, `CheckpointedConnectorWithPermSync` | `SlackCheckpoint`: `channel_ids`, per-channel completion map, `current_channel_access` (carries an in-flight channel's `ExternalAccess` across a checkpoint boundary) | Has a real `source_operations.py` (`SlackSourceOperations`), the enforced single import site for `slack_sdk`. `external_access` is assigned inline on yielded documents, not in a separate pass. |
| Slack (`slack/connector.py:SlackConnector`) | `SlimConnectorWithPermSync`, `CredentialsConnector`, `CheckpointedConnectorWithPermSync` | `SlackCheckpoint`: `channel_ids`, per-channel completion map, `current_channel_access` (carries an in-flight channel's `ExternalAccess` across a checkpoint boundary) | Has a real `source_operations.py` (`SlackSourceOperations`), the enforced single import site for `slack_sdk`. An exact `channels` or `exclude_channels` entry is a channel id or a name (`filter_channels`); the readable-channel listing check offers ids as values with names as labels, so a picked channel survives a rename. `external_access` is assigned inline on yielded documents, not in a separate pass. |
### 4.6.1 Outlook: one document per mail thread
@@ -17,10 +17,12 @@ from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.models import HierarchyNode
from onyx.connectors.slack.connector import (
SlackConnector,
channel_keys,
filter_channels,
get_channels,
get_channels_across_teams,
list_grid_team_ids,
normalize_channel_entries,
)
from onyx.connectors.slack.models import ChannelType
from onyx.connectors.slack.source_operations import SlackSourceOperations
@@ -54,11 +56,10 @@ def _filter_channels_for_permissions(
exclude_regex_enabled: bool = False,
) -> list[ChannelType]:
if channels_to_include and not include_regex_enabled:
available_channel_names = {channel["name"] for channel in all_channels}
channels_to_include = normalize_channel_entries(channels_to_include)
available_channels = channel_keys(all_channels)
available_channels_to_include = [
channel
for channel in channels_to_include
if channel in available_channel_names
channel for channel in channels_to_include if channel in available_channels
]
missing_channels = sorted(
set(channels_to_include) - set(available_channels_to_include)
@@ -32,6 +32,8 @@ from collections.abc import Iterator
from itertools import islice
from typing import Any, NoReturn
from pydantic import BaseModel
from onyx.connectors.capability_checks.findings import bounded_options
from onyx.connectors.capability_checks.models import (
CapabilityCheck,
@@ -52,9 +54,11 @@ from onyx.connectors.exceptions import (
)
from onyx.connectors.slack.config import SlackConnectorConfig
from onyx.connectors.slack.connector import (
channel_keys,
get_channels,
get_channels_across_teams,
list_grid_team_ids,
normalize_channel_entries,
validate_channel_regexes,
)
from onyx.connectors.slack.source_operations import (
@@ -661,7 +665,7 @@ class _ChannelPatternsCheck(CapabilityCheck[SlackConnectorConfig]):
class _ConfiguredChannelsVisibleCheck(CapabilityCheck[SlackConnectorConfig]):
"""Verifies every configured channel name is visible to the bot.
"""Verifies every configured channel id or name is visible to the bot.
Existed only as commented-out code in ``validate_connector_settings``
(removed as too slow for a synchronous request); resurrected here where slow
@@ -713,9 +717,9 @@ class _ConfiguredChannelsVisibleCheck(CapabilityCheck[SlackConnectorConfig]):
"(`conversations.list`), so the configured channels could not "
"be verified.",
)
visible_names = {channel["name"] for channel in all_channels}
configured_names = [name.removeprefix("#") for name in channels_to_include]
missing = sorted(set(configured_names) - visible_names)
visible_channels = channel_keys(all_channels)
configured = normalize_channel_entries(channels_to_include)
missing = sorted(set(configured) - visible_channels)
if missing:
raise ConnectorValidationError(
f"Configured channels are not visible to the bot: {missing}. "
@@ -726,20 +730,36 @@ class _ConfiguredChannelsVisibleCheck(CapabilityCheck[SlackConnectorConfig]):
return None
class _ListedChannel(BaseModel):
channel: dict[str, Any]
# The Grid workspace the channel was listed in, which tells two
# same-named channels apart. None off Grid.
workspace: str | None
def _grid_workspaces(slack_client: SlackSourceOperations) -> dict[str, str | None]:
"""Grid team id to workspace name via ``auth.teams.list``."""
return {
team["id"]: team.get("name")
for page in slack_client.list_teams()
for team in page.teams
if team.get("id")
}
def _iter_public_channels(
slack_client: SlackSourceOperations,
) -> Iterator[dict[str, Any]]:
) -> Iterator[_ListedChannel]:
"""Every unarchived public channel, read page by page. On Enterprise Grid,
lists each workspace in turn, as the connector does. The connector matches
``channels`` by name, so channels that share a name across workspaces are
one option."""
team_ids: list[str | None] = (
[*list_grid_team_ids(slack_client)]
lists each workspace in turn, as the connector does. A channel shared into
several workspaces is listed once, by id."""
workspaces: dict[str | None, str | None] = (
{**_grid_workspaces(slack_client)}
if _auth_test(slack_client).enterprise_id
else [None]
else {None: None}
)
seen_names: set[str] = set()
for team_id in team_ids:
seen_ids: set[str] = set()
for team_id, workspace in workspaces.items():
for page in slack_client.list_channels(
variant=SlackChannelVariant.PUBLIC,
channel_types=["public_channel"],
@@ -747,10 +767,10 @@ def _iter_public_channels(
team_id=team_id,
):
for channel in page.channels:
if channel["name"] in seen_names:
if channel["id"] in seen_ids:
continue
seen_names.add(channel["name"])
yield channel
seen_ids.add(channel["id"])
yield _ListedChannel(channel=channel, workspace=workspace)
class _ReadableChannelListingCheck(CapabilityCheck):
@@ -771,13 +791,14 @@ class _ReadableChannelListingCheck(CapabilityCheck):
def run(self, context: CapabilityCheckContext) -> CheckOutput:
try:
# The connector matches ``channels`` against bare channel names.
# The id survives a rename. The label is the name the admin reads.
finding: OptionsFinding = bounded_options(
_iter_public_channels(_slack_client(context)),
key=CheckFindingKey.READABLE_CHANNELS,
field_name=_CHANNELS_FIELD,
value=lambda channel: channel["name"],
label=lambda channel: channel["name"],
value=lambda listed: listed.channel["id"],
label=lambda listed: listed.channel["name"],
detail=lambda listed: listed.workspace,
)
except SlackApiError as e:
_raise_for_slack_api_error(
+35 -12
View File
@@ -468,34 +468,56 @@ def filter_channels(
if channels_to_include:
if not include_regex_enabled:
channels_to_include = normalize_channel_entries(channels_to_include)
_validate_channels_exist(all_channels, channels_to_include)
filtered_channels = [
channel
for channel in filtered_channels
if _channel_name_matches(
channel["name"], channels_to_include, include_regex_enabled
)
if _channel_matches(channel, channels_to_include, include_regex_enabled)
]
# unlike includes, exclude names aren't validated — excluding a missing channel is harmless
# unlike includes, exclude entries aren't validated: excluding a missing channel is harmless
if channels_to_exclude:
if not exclude_regex_enabled:
channels_to_exclude = normalize_channel_entries(channels_to_exclude)
filtered_channels = [
channel
for channel in filtered_channels
if not _channel_name_matches(
channel["name"], channels_to_exclude, exclude_regex_enabled
)
if not _channel_matches(channel, channels_to_exclude, exclude_regex_enabled)
]
return filtered_channels
def _channel_name_matches(
channel_name: str, patterns: list[str], regex_enabled: bool
# A channel id: a type letter, then uppercase letters and digits. A channel
# name is lowercase, so the two never collide.
_CHANNEL_ID_PATTERN = re.compile(r"[CGD][A-Z0-9]{8,}")
def normalize_channel_entries(entries: list[str]) -> list[str]:
"""Exact config entries as Slack stores them: an id as given, a typed name
lowercase without its leading ``#``."""
return [
entry
if _CHANNEL_ID_PATTERN.fullmatch(entry)
else entry.removeprefix("#").lower()
for entry in entries
]
def _channel_matches(
channel: ChannelType, patterns: list[str], regex_enabled: bool
) -> bool:
"""An exact entry names the channel by id, which survives a rename, or by
name. A regex matches the name only."""
if regex_enabled:
return any(re.fullmatch(pattern, channel_name) for pattern in patterns)
return channel_name in patterns
return any(re.fullmatch(pattern, channel["name"]) for pattern in patterns)
return channel["id"] in patterns or channel["name"] in patterns
def channel_keys(channels: list[ChannelType]) -> set[str]:
"""Every string an exact config entry may use for one of ``channels``."""
return {key for channel in channels for key in (channel["id"], channel["name"])}
def _validate_channels_exist(
@@ -503,9 +525,10 @@ def _validate_channels_exist(
) -> None:
# fail loudly on an unknown channel so the user knows one of the
# channels they've specified is typo'd or private
known_channels = channel_keys(all_channels)
all_channel_names = {channel["name"] for channel in all_channels}
for channel in channels_to_include:
if channel not in all_channel_names:
if channel not in known_channels:
raise ValueError(
f"Channel '{channel}' not found in workspace. "
f"Available channels (Showing {len(all_channel_names)} of "
@@ -287,6 +287,33 @@ class TestFetchChannelPermissionsGrid:
== "C_INCLUDED"
)
def test_channel_filter_matches_channel_ids(self) -> None:
slack_client = MagicMock()
included = _channel("CINCLUDED01", name="renamed", is_private=True)
excluded = _channel("CEXCLUDED01", name="excluded", is_private=True)
with patch(
"ee.onyx.external_permissions.slack.doc_sync.get_channels"
) as mock_get:
mock_get.side_effect = [[], [included, excluded]] # public, private
slack_client.list_channel_members.return_value = iter(
[SlackChannelMembersPage(members=["U1"])]
)
workspace_perm = _fetch_workspace_permissions({"U1": "u1@x.com"})
# The typed name keeps its case: an id is matched as given.
result = _fetch_channel_permissions(
slack_client=slack_client,
workspace_permissions=workspace_perm,
user_id_to_email_map={"U1": "u1@x.com"},
channels_to_include=["CINCLUDED01"],
)
assert set(result) == {"CINCLUDED01"}
slack_client.list_channel_members.assert_called_once()
assert (
slack_client.list_channel_members.call_args.kwargs["channel_id"]
== "CINCLUDED01"
)
def test_channel_filter_skips_missing_included_channels(self) -> None:
slack_client = MagicMock()
included = _channel("C_INCLUDED", name="included", is_private=True)
@@ -969,9 +969,22 @@ def test_configured_channels_all_visible_passes() -> None:
gateway.list_channels.side_effect = _pages(
SlackChannelsPage(channels=[{"id": "C1", "name": "general"}])
)
# The ``#`` prefix must be stripped, mirroring the connector's ``channels``
# setter.
context = _context(gateway, connector_specific_config={"channels": ["#general"]})
# A typed name is matched without its ``#`` and in any case, as the
# connector normalizes it.
context = _context(gateway, connector_specific_config={"channels": ["#General"]})
# Under test and postcondition.
_run("slack_configured_channels_visible", context)
def test_configured_channels_by_id_pass_after_a_rename() -> None:
"""Verifies the config check accepts a channel id whose name changed."""
# Precondition.
gateway = _gateway()
gateway.list_channels.side_effect = _pages(
SlackChannelsPage(channels=[{"id": "C0GENERAL1", "name": "general-renamed"}])
)
context = _context(gateway, connector_specific_config={"channels": ["C0GENERAL1"]})
# Under test and postcondition.
_run("slack_configured_channels_visible", context)
@@ -1017,7 +1030,7 @@ def test_configured_channels_regex_mode_skips_probe() -> None:
# slack_readable_channel_listing
def test_readable_channel_listing_finds_channel_names_as_channel_options() -> None:
def test_readable_channel_listing_finds_channel_ids_as_channel_options() -> None:
# Precondition.
gateway = _gateway()
gateway.list_channels.side_effect = lambda **_: iter(
@@ -1038,8 +1051,8 @@ def test_readable_channel_listing_finds_channel_names_as_channel_options() -> No
key=CheckFindingKey.READABLE_CHANNELS,
field_name="channels",
options=[
FindingOption(value="general", label="general"),
FindingOption(value="random", label="random"),
FindingOption(value="C1", label="general"),
FindingOption(value="C2", label="random"),
],
)
]
@@ -1056,12 +1069,15 @@ def test_readable_channel_listing_lists_each_grid_workspace() -> None:
gateway = _gateway()
gateway.check_auth.return_value = _auth_response(enterprise_id="E1")
gateway.list_teams.side_effect = _pages(
SlackTeamsPage(teams=[{"id": "T1"}, {"id": "T2"}])
SlackTeamsPage(
teams=[{"id": "T1", "name": "Acme"}, {"id": "T2", "name": "Labs"}]
)
)
channels_by_team = {
"T1": [{"id": "C1", "name": "general"}],
# A shared channel appears in both workspaces, and another workspace
# has its own channel with the same name: both are one option.
# A shared channel appears in both workspaces and is one option. Another
# workspace's own channel with the same name is a second option, told
# apart by its workspace.
"T2": [
{"id": "C1", "name": "general"},
{"id": "C3", "name": "general"},
@@ -1080,7 +1096,13 @@ def test_readable_channel_listing_lists_each_grid_workspace() -> None:
# Postcondition.
listing: CheckFinding = findings[0]
assert isinstance(listing, OptionsFinding)
assert [option.value for option in listing.options] == ["general", "eng"]
assert [
(option.value, option.label, option.detail) for option in listing.options
] == [
("C1", "general", "Acme"),
("C3", "general", "Labs"),
("C2", "eng", "Labs"),
]
def test_readable_channel_listing_stops_at_the_option_cap() -> None:
@@ -1,6 +1,7 @@
"""Channel include/exclude filtering for the Slack connector: excludes apply
after includes, support exact-name and full-match regex modes, and never fail
on names absent from the workspace."""
after includes, exact entries match a channel id or name, regex entries match
the name in full, and excludes never fail on entries absent from the
workspace."""
from typing import Any, cast
@@ -12,7 +13,7 @@ from onyx.connectors.slack.models import ChannelType
def _channel(name: str) -> ChannelType:
base: dict[str, Any] = {"id": f"C-{name}", "name": name}
base: dict[str, Any] = {"id": f"C0{name.upper().replace('-', '')}", "name": name}
return cast(ChannelType, base)
@@ -116,6 +117,48 @@ def test_exclude_regex_is_full_match() -> None:
assert result == CHANNELS
def test_include_exact_matches_channel_id() -> None:
result = filter_channels(CHANNELS, ["C0SUPPORT", "C0BILLINGALERTS"], False)
assert _names(result) == ["support", "billing-alerts"]
def test_include_exact_mixes_ids_and_names() -> None:
result = filter_channels(CHANNELS, ["C0SUPPORT", "general"], False)
assert _names(result) == ["general", "support"]
def test_exact_entries_take_typed_names_in_any_case_with_a_hash() -> None:
result = filter_channels(
CHANNELS,
["#Support", "C0GENERAL"],
False,
channels_to_exclude=["General"],
exclude_regex_enabled=False,
)
assert _names(result) == ["support"]
def test_include_regex_never_matches_channel_id() -> None:
result = filter_channels(CHANNELS, ["C0.*"], True)
assert result == []
def test_exclude_exact_matches_channel_id() -> None:
result = filter_channels(
CHANNELS,
None,
False,
channels_to_exclude=["C0SUPPORT", "infra-alerts"],
exclude_regex_enabled=False,
)
assert _names(result) == ["general", "billing-alerts", "deploy-notifications"]
def test_include_validation_raises_for_unknown_channel_id() -> None:
with pytest.raises(ValueError, match="C0RENAMED.*not found in workspace"):
filter_channels(CHANNELS, ["C0SUPPORT", "C0RENAMED"], False)
def test_include_validation_still_raises_for_unknown_channel() -> None:
with pytest.raises(ValueError, match="not found in workspace"):
filter_channels(CHANNELS, ["typo-channel"], False)
+3
View File
@@ -3134,6 +3134,9 @@
"title": "صلاحيات إدارة الموصل"
},
"title": "إعدادات الموصل"
},
"optionsListInput": {
"placeholder": "اختر {label} من القائمة أو أدخله"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "Verwaltungszugriff für den Connector"
},
"title": "Connector-Einstellungen"
},
"optionsListInput": {
"placeholder": "{label} aus der Liste wählen oder eingeben"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "Connector Manage Access"
},
"title": "Connector Settings"
},
"optionsListInput": {
"placeholder": "Pick {label} from the list, or type one"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "Acceso de gestión del conector"
},
"title": "Configuración del conector"
},
"optionsListInput": {
"placeholder": "Elige {label} de la lista o escríbelo"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "Accès de gestion du connecteur"
},
"title": "Paramètres du connecteur"
},
"optionsListInput": {
"placeholder": "Choisissez {label} dans la liste ou saisissez-en"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "コネクタの管理アクセス"
},
"title": "コネクタ設定"
},
"optionsListInput": {
"placeholder": "リストから {label} を選択するか入力"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "커넥터 관리 액세스"
},
"title": "커넥터 설정"
},
"optionsListInput": {
"placeholder": "목록에서 {label} 선택 또는 입력"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "Acesso de gerenciamento do conector"
},
"title": "Configurações do conector"
},
"optionsListInput": {
"placeholder": "Escolha {label} na lista ou digite"
}
},
"costOverrides": {
+3
View File
@@ -3134,6 +3134,9 @@
"title": "连接器管理权限"
},
"title": "连接器设置"
},
"optionsListInput": {
"placeholder": "从列表中选择{label}或输入"
}
},
"costOverrides": {
@@ -0,0 +1,119 @@
/** Listing-backed config fields: the first non-empty listing wins, stored ids
* show as their labels, and the pair's report is read before the credential's. */
import {
findingOptions,
labelFindingValues,
storedCheckResults,
} from "@/lib/connectors/checks/findings";
import type {
CapabilityCheckResult,
CheckFinding,
} from "@/lib/connectors/checks/types";
import type { ConnectionConfiguration } from "@/lib/connectors/types";
import type { CredentialCheckReport } from "@/lib/credentials/types";
function result(findings: CheckFinding[]): CapabilityCheckResult {
return {
capability: "indexing",
check_id: "listing",
display_name: "Listing",
required: false,
status: "passed",
message: "",
remediation: null,
docs_link: null,
duration_ms: null,
findings,
};
}
function channels(options: { value: string; label: string }[]): CheckFinding {
return {
type: "options",
key: "readable_channels",
field_name: "channels",
options: options.map((option) => ({ ...option, detail: null })),
truncated: false,
};
}
const GENERAL = { value: "C1", label: "general" };
test("findingOptions skips an empty listing for a later full one", () => {
const checks = [result([channels([])]), result([channels([GENERAL])])];
expect(findingOptions(checks, "readable_channels")).toEqual([
{ ...GENERAL, detail: null },
]);
expect(findingOptions(checks, "shared_drives")).toEqual([]);
});
test("labelFindingValues shows ids by label and leaves the rest", () => {
const configuration: ConnectionConfiguration = {
description: "",
values: [],
advanced_values: [
{
type: "tab",
name: "mode",
tabs: [
{
label: "Channels",
value: "channels",
fields: [
{
type: "list",
label: "Channels",
name: "channels",
optionsFinding: "readable_channels",
},
],
},
],
},
{ type: "list", label: "Patterns", name: "patterns" },
],
};
const labeled = labelFindingValues(
configuration,
{ channels: ["C1", "typed-name"], patterns: ["C1"], flag: true },
[result([channels([GENERAL])])]
);
expect(labeled).toEqual({
channels: ["general", "typed-name"],
patterns: ["C1"],
flag: true,
});
});
test("storedCheckResults reads the pair's report before the credential's", () => {
const report = (
connectorId: number | null,
label: string
): CredentialCheckReport => ({
credential_id: 7,
connector_id: connectorId,
run_status: "completed",
run_started_at: null,
report: {
checked_at: "2026-10-13T00:00:00Z",
check_results: [result([channels([{ value: "C1", label }])])],
},
time_updated: "2026-10-13T00:00:00Z",
});
const reports = [report(3, "from-pair"), report(null, "from-credential")];
const reportFor = (credentialId: number, connectorId: number | null = null) =>
reports.find(
(candidate) =>
candidate.credential_id === credentialId &&
candidate.connector_id === connectorId
) ?? null;
const labels = (connectorId: number | null) =>
findingOptions(
storedCheckResults(reportFor, { credentialId: 7, connectorId }),
"readable_channels"
).map((option) => option.label);
expect(labels(3)).toEqual(["from-pair"]);
expect(labels(null)).toEqual(["from-credential"]);
expect(labels(9)).toEqual(["from-credential"]);
});
+81
View File
@@ -0,0 +1,81 @@
import type {
CapabilityCheckResult,
CheckFinding,
CheckFindingKey,
FindingOption,
} from "@/lib/connectors/checks/types";
import { configFields } from "@/lib/connectors/checks/formState";
import type { ConnectionConfiguration } from "@/lib/connectors/types";
import type { CredentialCheckReports } from "@/lib/credentials/hooks";
/** Anything that carries findings: a draft check state or a stored result. */
interface WithFindings {
findings: CheckFinding[];
}
/** The first non-empty options the checks listed under `key`, else empty. */
export function findingOptions(
checks: readonly WithFindings[],
key: CheckFindingKey
): FindingOption[] {
for (const check of checks) {
for (const finding of check.findings) {
if (
finding.type === "options" &&
finding.key === key &&
finding.options.length > 0
) {
return finding.options;
}
}
}
return [];
}
/** The stored reports a form or page may read listings from. */
export interface StoredReportScope {
credentialId: number;
/** The pair being edited or shown, whose own report is read first. */
connectorId: number | null;
}
/** The pair's stored check results, then the credential's own. */
export function storedCheckResults(
reportFor: CredentialCheckReports["reportFor"],
scope: StoredReportScope
): CapabilityCheckResult[] {
const reports = [
scope.connectorId === null
? null
: reportFor(scope.credentialId, scope.connectorId),
reportFor(scope.credentialId),
];
return reports.flatMap((report) => report?.report?.check_results ?? []);
}
/** The config with listing-backed entries shown by option label. Entries the listing lacks stay as stored. */
export function labelFindingValues(
configuration: ConnectionConfiguration,
config: Record<string, unknown>,
checks: readonly WithFindings[]
): Record<string, unknown> {
const labeled: Record<string, unknown> = { ...config };
for (const field of configFields([
...configuration.values,
...configuration.advanced_values,
])) {
if (field.type !== "list" || field.optionsFinding === undefined) continue;
const stored = config[field.name];
if (!Array.isArray(stored)) continue;
const labels = new Map(
findingOptions(checks, field.optionsFinding).map((option) => [
option.value,
option.label,
])
);
labeled[field.name] = stored.map((entry) =>
typeof entry === "string" ? (labels.get(entry) ?? entry) : entry
);
}
return labeled;
}
+9 -4
View File
@@ -3,17 +3,22 @@ import type { ValidSources } from "@/lib/connectors/types/source";
import { withoutFormOnlyFields } from "@/lib/connectors/utils";
type ConfigField = ConnectionConfiguration["values"][number];
type ConfigValueField = Exclude<ConfigField, { type: "tab" }>;
/** Every config field name, tab contents included; tab selectors are form
/** Every config field, tab contents included. Tab selectors are form
* controls, not config. */
function configFieldNames(fields: ConfigField[]): string[] {
export function configFields(fields: ConfigField[]): ConfigValueField[] {
return fields.flatMap((field) =>
field.type === "tab"
? field.tabs.flatMap((tab) => configFieldNames(tab.fields))
: [field.name]
? field.tabs.flatMap((tab) => configFields(tab.fields))
: [field]
);
}
function configFieldNames(fields: ConfigField[]): string[] {
return configFields(fields).map((field) => field.name);
}
/**
* The connector config an unsaved form holds, for a draft check run or plan.
* It takes only the configuration's own fields without the form-only ones
@@ -77,6 +77,7 @@ function check(
duration_ms: null,
from_cache: false,
validates_binding: false,
findings: [],
...overrides,
};
}
+43 -1
View File
@@ -28,18 +28,28 @@ import { useConnectorConfiguration } from "@/lib/connectors/connectors";
import { splitCredentialBoundFields } from "@/lib/connectors/utils";
import { toWireAccess } from "@/lib/connectors/accessType";
import { checksProgress } from "@/lib/connectors/checks/progress";
import {
findingOptions,
storedCheckResults,
type StoredReportScope,
} from "@/lib/connectors/checks/findings";
import type { CredentialRef } from "@/lib/credentials/types";
import { useSavedDraftCredential } from "@/lib/credentials/hooks";
import {
useCredentialCheckReports,
useSavedDraftCredential,
} from "@/lib/credentials/hooks";
import { toCredentialRequest } from "@/lib/credentials/utils";
import type {
CapabilityCheckResult,
CapabilityCheckStatus,
CheckFindingKey,
ConnectorChecksStatus,
DraftCheckPlan,
DraftCheckRunSnapshot,
DraftCheckState,
DraftCheckStateKind,
DraftRerunMode,
FindingOption,
} from "@/lib/connectors/checks/types";
import type { ConfigurableSources } from "@/lib/connectors/types/source";
import type { AccessType } from "@/lib/types";
@@ -114,6 +124,7 @@ function toCheckResult(
remediation: check.remediation,
docs_link: check.docs_link,
duration_ms: check.duration_ms,
findings: check.findings,
};
}
@@ -510,6 +521,37 @@ export function useResetChecksOnCredentialChange({
}, [stale, staleRunId, credentialKey, session?.request, sessionKey, mutate]);
}
/** Options a discovery check listed under `key`: the form's latest run, else the stored reports in `scope`. Starts no run. */
export function useFindingOptions(
source: ConfigurableSources,
key: CheckFindingKey,
scope: StoredReportScope | null
): FindingOption[] {
const { data: session } = useSWR<ConnectorChecksSession>(
SWR_KEYS.connectorCheckSession(source),
null
);
const runId: string | null = session?.runId ?? null;
const { data: run } = useSWR<DraftCheckRunSnapshot>(
runId ? SWR_KEYS.connectorCheckRun(runId) : null,
null
);
const { reportFor } = useCredentialCheckReports(source);
const { savedDraft } = useSavedDraftCredential(source);
// The form's account: a saved credential, else the draft a run saved the
// typed account as. A run of another account is stale until the reset.
const accountId: number | null =
scope?.credentialId ?? savedDraft?.credential_id ?? null;
return useMemo(() => {
const fromRun: FindingOption[] =
run && run.credential_id === accountId
? findingOptions(run.checks, key)
: [];
if (fromRun.length > 0 || scope === null) return fromRun;
return findingOptions(storedCheckResults(reportFor, scope), key);
}, [run, accountId, key, scope, reportFor]);
}
const PLAN_DEBOUNCE_MS = 300;
/** `value`, once it has held still for `delayMs`. */
+42
View File
@@ -24,6 +24,45 @@ export type CapabilityCheckStatus =
| "indeterminate"
| "skipped";
/** What a discovery check lists, named by `CheckFindingKey` on the backend. */
export type CheckFindingKey =
| "shared_drives"
| "visible_organization_users"
| "visible_spaces"
| "readable_channels"
| "readable_mailboxes"
| "readable_drives";
/** One choice a listing offers a config field. */
export interface FindingOption {
/** What the config field stores: an id or a URL. */
value: string;
/** What the admin reads. */
label: string;
detail: string | null;
}
export interface OptionsFinding {
type: "options";
key: CheckFindingKey;
/** The config field the options are for. */
field_name: string | null;
options: FindingOption[];
/** The listing stopped at the backend's option cap. */
truncated: boolean;
}
export interface CountFinding {
type: "count";
key: CheckFindingKey;
field_name: string | null;
count: number;
/** The listing stopped at a cap, so the real count is at least `count`. */
at_least: boolean;
}
export type CheckFinding = OptionsFinding | CountFinding;
/** One finished check, as the checks card shows it. */
export interface CapabilityCheckResult {
capability: CredentialCapability;
@@ -37,6 +76,8 @@ export interface CapabilityCheckResult {
remediation: string | null;
docs_link: string | null;
duration_ms: number | null;
/** What a passing discovery check listed. Empty for every other check. */
findings: CheckFinding[];
}
/** A check's state in a draft run. */
@@ -90,6 +131,7 @@ export interface DraftCheckState {
from_cache: boolean;
/** The check proves the credential works with the credential-bound fields. */
validates_binding: boolean;
findings: CheckFinding[];
}
export interface DraftCheckRunSnapshot {
@@ -0,0 +1,151 @@
/** The listing a picker offers follows the form's account: the account's own
* run first, else its stored report, never another account's run. */
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { SWRConfig, useSWRConfig } from "swr";
import { useFindingOptions } from "@/lib/connectors/checks/hooks";
import type { StoredReportScope } from "@/lib/connectors/checks/findings";
import type {
CapabilityCheckResult,
CheckFinding,
DraftCheckRunSnapshot,
DraftCheckState,
} from "@/lib/connectors/checks/types";
import { ValidSources } from "@/lib/connectors/types/source";
import type { CredentialCheckReport } from "@/lib/credentials/types";
import { SWR_KEYS } from "@/lib/swr-keys";
const SOURCE = ValidSources.Slack;
function listing(label: string): CheckFinding {
return {
type: "options",
key: "readable_channels",
field_name: "channels",
options: [{ value: "C1", label, detail: null }],
truncated: false,
};
}
function checkState(findings: CheckFinding[]): DraftCheckState {
return {
check_id: "listing",
display_name: "Listing",
capability: "indexing",
required: false,
state: "passed",
message: "",
missing_fields: [],
invalid_fields: [],
remediation: null,
docs_link: null,
duration_ms: 1,
from_cache: false,
validates_binding: false,
findings,
};
}
function checkResult(findings: CheckFinding[]): CapabilityCheckResult {
return {
capability: "indexing",
check_id: "listing",
display_name: "Listing",
required: false,
status: "passed",
message: "",
remediation: null,
docs_link: null,
duration_ms: 1,
findings,
};
}
function report(credentialId: number, label: string): CredentialCheckReport {
return {
credential_id: credentialId,
connector_id: null,
run_status: "completed",
run_started_at: null,
report: {
checked_at: "2026-10-13T00:00:00Z",
check_results: [checkResult([listing(label)])],
},
time_updated: "2026-10-13T00:00:00Z",
};
}
const REPORTS: CredentialCheckReport[] = [
report(1, "from-report-1"),
report(2, "from-report-2"),
];
jest.mock("@/lib/credentials/hooks", () => ({
useCredentialCheckReports: () => ({
reportFor: (credentialId: number, connectorId: number | null = null) =>
REPORTS.find(
(candidate) =>
candidate.credential_id === credentialId &&
candidate.connector_id === connectorId
) ?? null,
rerun: async () => undefined,
}),
useSavedDraftCredential: () => ({
savedDraft: null,
rememberDraft: async () => undefined,
}),
}));
const RUN: DraftCheckRunSnapshot = {
run_id: "run-1",
draft_key: "draft",
source: SOURCE,
credential_id: 1,
access_type: "public",
status: "completed",
form_errors: {},
unknown_fields: [],
checks: [checkState([listing("from-run-1")])],
};
function wrapper({ children }: { children: ReactNode }) {
return (
<SWRConfig value={{ provider: () => new Map() }}>{children}</SWRConfig>
);
}
function scopeFor(credentialId: number): StoredReportScope {
return { credentialId, connectorId: null };
}
test("the account's run wins, and another account's run is ignored", async () => {
const { result, rerender } = renderHook(
(props: { scope: StoredReportScope }) => ({
labels: useFindingOptions(SOURCE, "readable_channels", props.scope).map(
(option) => option.label
),
swr: useSWRConfig(),
}),
{ initialProps: { scope: scopeFor(1) }, wrapper }
);
expect(result.current.labels).toEqual(["from-report-1"]);
// A run of account 1 seeds the session and run keys as a check start does.
await act(async () => {
await result.current.swr.mutate(
SWR_KEYS.connectorCheckSession(SOURCE),
{ runId: RUN.run_id },
{ revalidate: false }
);
await result.current.swr.mutate(
SWR_KEYS.connectorCheckRun(RUN.run_id),
RUN,
{ revalidate: false }
);
});
expect(result.current.labels).toEqual(["from-run-1"]);
// Switching to account 2 before the session resets reads its own report.
rerender({ scope: scopeFor(2) });
expect(result.current.labels).toEqual(["from-report-2"]);
});
@@ -26,6 +26,11 @@ function renderDriveFields(currentCredential: Credential<unknown> | null) {
values={values}
connector={ValidSources.GoogleDrive}
currentCredential={currentCredential}
reportScope={
currentCredential
? { credentialId: currentCredential.id, connectorId: null }
: null
}
/>
</Formik>
);
+6 -5
View File
@@ -1484,10 +1484,10 @@ export const connectorConfigs: Record<
query: "Enter channels to include:",
label: "Channels",
name: "channels",
description: `Specify 0 or more channels to index. For example, specifying the channel "support" will cause us to only index all content within the "#support" channel. If no channels are specified, all channels in your workspace will be indexed.`,
description: `Specify 0 or more channels to index. Pick a channel from the list to follow it by id, so a rename does not break indexing, or type the name of a channel the list does not show, such as a private channel the bot was invited to. If no channels are specified, all channels in your workspace will be indexed.`,
optional: true,
// Slack Channels can only be lowercase
transform: (values) => values.map((value) => value.toLowerCase()),
optionsFinding: "readable_channels",
plainListCondition: (values) => values.channel_regex_enabled === true,
},
{
type: "checkbox",
@@ -1506,8 +1506,9 @@ For example, specifying \`.*-support.*\` as a "channel" will cause the connector
name: "exclude_channels",
description: `Specify 0 or more channels to exclude. Exclusions are applied after the "Channels" filter above, so a channel matched by both is excluded. If no channels are specified, nothing is excluded.`,
optional: true,
// Slack Channels can only be lowercase
transform: (values) => values.map((value) => value.toLowerCase()),
optionsFinding: "readable_channels",
plainListCondition: (values) =>
values.exclude_channel_regex_enabled === true,
},
{
type: "checkbox",
+5
View File
@@ -1,4 +1,5 @@
/** The schema a connector's creation form is rendered from. */
import type { CheckFindingKey } from "@/lib/connectors/checks/types";
import type { CredentialValues } from "@/lib/credentials/types";
export type InputType =
@@ -51,6 +52,10 @@ export interface ListOption extends Option {
type: "list";
default?: string[];
transform?: (values: string[]) => string[];
/** Discovery finding whose options the field offers: stores the value, shows the label. Typed entries stay allowed. */
optionsFinding?: CheckFindingKey;
/** When true the field takes typed entries only, such as regex patterns. */
plainListCondition?: (values: Record<string, unknown>) => boolean;
}
export interface StringPairListOption extends Option {
@@ -32,6 +32,7 @@ function ZoomForm() {
values={values}
connector={ValidSources.Zoom}
currentCredential={null}
reportScope={null}
/>
))}
</>
+14 -5
View File
@@ -406,8 +406,11 @@ const CREDENTIAL_CHECK_POLL_MS = 2000;
/** What `useCredentialCheckReports` returns. */
export interface CredentialCheckReports {
/** A credential's own report (not a connector's), or null if it has none. */
reportFor: (credentialId: number) => CredentialCheckReport | null;
/** The credential's report for `connectorId`, or its own for null. */
reportFor: (
credentialId: number,
connectorId?: number | null
) => CredentialCheckReport | null;
/** Starts the checks on one credential; resolves once the run is queued. */
rerun: (credentialId: number) => Promise<void>;
}
@@ -431,12 +434,18 @@ export function useCredentialCheckReports(
}
);
return {
reportFor: (credentialId) =>
// Stable per load, so a consumer can memoize on it.
const reportFor = useCallback(
(credentialId: number, connectorId: number | null = null) =>
data?.find(
(report) =>
report.credential_id === credentialId && report.connector_id === null
report.credential_id === credentialId &&
report.connector_id === connectorId
) ?? null,
[data]
);
return {
reportFor,
rerun: async (credentialId) => {
await startCredentialCheckRun(credentialId);
await refresh();
+5 -1
View File
@@ -1,4 +1,5 @@
import type { SWRResponse } from "swr";
import type { CapabilityCheckResult } from "@/lib/connectors/checks/types";
import type { CredentialSpec } from "@/lib/credentials/types/spec";
import type { ValidSources } from "@/lib/connectors/types/source";
import type { TypedFile } from "@/lib/connectors/fileTypes";
@@ -176,7 +177,10 @@ export interface CredentialCheckReport {
connector_id: number | null;
run_status: CredentialCheckRunStatus;
run_started_at: string | null;
report: { checked_at: string } | null;
report: {
checked_at: string;
check_results: CapabilityCheckResult[];
} | null;
time_updated: string;
}
@@ -707,6 +707,10 @@ export function AddConnectorForm({ connector, edit }: AddConnectorFormProps) {
{(formikProps) => {
const busy = uploading || creatingConnector || planning;
const formCredential = accountFor(formikProps.values);
const formCredentialId: number | null =
formCredential !== null && !isDraftCredential(formCredential)
? formCredential.id
: null;
const canCreate = noCredentials || formCredential !== null;
const newAccountReady =
typedDraft(connector, accountSchema, formikProps.values) !== null;
@@ -730,10 +734,7 @@ export function AddConnectorForm({ connector, edit }: AddConnectorFormProps) {
const credentialSwapped: boolean =
edit !== undefined &&
formCredential !== null &&
!(
"id" in formCredential &&
formCredential.id === edit.ccPair.credential.id
);
formCredentialId !== edit.ccPair.credential.id;
return (
<ConnectorChecksGate
source={connector}
@@ -955,6 +956,15 @@ export function AddConnectorForm({ connector, edit }: AddConnectorFormProps) {
values={formikProps.values}
connector={connector}
currentCredential={formCredential}
reportScope={
formCredentialId === null
? null
: {
credentialId: formCredentialId,
connectorId:
edit?.ccPair.connector.id ?? null,
}
}
disabled={!formUnlocked}
>
{edit && editsFileList && (
@@ -3,6 +3,7 @@ import CredentialSubText from "@/lib/credentials/components/CredentialFields";
import type { ConnectionConfiguration } from "@/lib/connectors/types";
import type { ConfigurableSources } from "@/lib/connectors/types/source";
import type { CredentialValues } from "@/lib/credentials/types";
import type { StoredReportScope } from "@/lib/connectors/checks/findings";
import { RenderField } from "./FieldRendering";
import { useFormikContext } from "formik";
@@ -13,6 +14,8 @@ export interface ConnectorConfigFieldsProps {
values: any;
connector: ConfigurableSources;
currentCredential: CredentialValues | null;
/** The stored check reports that can list a field's options. */
reportScope: StoredReportScope | null;
}
export default function ConnectorConfigFields({
@@ -20,6 +23,7 @@ export default function ConnectorConfigFields({
values,
connector,
currentCredential,
reportScope,
}: ConnectorConfigFieldsProps) {
const { setFieldValue } = useFormikContext<any>(); // Get Formik's context functions
@@ -68,6 +72,7 @@ export default function ConnectorConfigFields({
values={values}
connector={connector}
currentCredential={currentCredential}
reportScope={reportScope}
/>
))}
</>
@@ -61,6 +61,8 @@ export default function CredentialBoundFields({
values={values}
connector={connector}
currentCredential={currentCredential}
// A bound field is read before the checks run, so no listing.
reportScope={null}
/>
{error && (
<Text font="secondary-body" color="status-error-05" role="alert">
@@ -26,6 +26,8 @@ import FileDropzoneField from "@/refresh-components/form/FileDropzoneField";
import { FormikField } from "@/refresh-components/form/FormikField";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
import FindingListField from "@/views/admin/connectors/AddConnectorPage/form/FindingListField";
import type { StoredReportScope } from "@/lib/connectors/checks/findings";
import { getSourceDisplayName } from "@/lib/sources";
import { markdown } from "@opal/utils";
@@ -37,6 +39,7 @@ interface TabsFieldProps {
values: any;
connector: ConfigurableSources;
currentCredential: CredentialValues | null;
reportScope: StoredReportScope | null;
}
const TabsField: FC<TabsFieldProps> = ({
@@ -44,6 +47,7 @@ const TabsField: FC<TabsFieldProps> = ({
values,
connector,
currentCredential,
reportScope,
}) => {
const t = useTranslations("admin.connectorsList");
const { setFieldTouched, setFieldValue } = useFormikContext<FormValues>();
@@ -126,6 +130,7 @@ const TabsField: FC<TabsFieldProps> = ({
values={values}
connector={connector}
currentCredential={currentCredential}
reportScope={reportScope}
/>
);
})}
@@ -257,6 +262,8 @@ interface RenderFieldProps {
values: any;
connector: ConfigurableSources;
currentCredential: CredentialValues | null;
/** The stored check reports that can list a field's options. */
reportScope: StoredReportScope | null;
}
export const RenderField: FC<RenderFieldProps> = ({
@@ -264,6 +271,7 @@ export const RenderField: FC<RenderFieldProps> = ({
values,
connector,
currentCredential,
reportScope,
}) => {
const t = useTranslations("admin.connectorsList");
const { setFieldValue } = useFormikContext<FormValues>(); // Get Formik's context functions
@@ -315,6 +323,7 @@ export const RenderField: FC<RenderFieldProps> = ({
values={values}
connector={connector}
currentCredential={currentCredential}
reportScope={reportScope}
/>
);
}
@@ -345,13 +354,30 @@ export const RenderField: FC<RenderFieldProps> = ({
subDescription={richDescription}
suffix={optionalSuffix}
>
<TextListField
name={field.name}
placeholder={t("listInput.placeholder", {
label: label.toLowerCase(),
})}
disabled={disabled}
/>
{field.optionsFinding !== undefined ? (
<FindingListField
name={field.name}
connector={connector}
findingKey={field.optionsFinding}
reportScope={reportScope}
plain={field.plainListCondition?.(values) === true}
placeholder={t("optionsListInput.placeholder", {
label: label.toLowerCase(),
})}
plainPlaceholder={t("listInput.placeholder", {
label: label.toLowerCase(),
})}
disabled={disabled}
/>
) : (
<TextListField
name={field.name}
placeholder={t("listInput.placeholder", {
label: label.toLowerCase(),
})}
disabled={disabled}
/>
)}
</InputVertical>
) : field.type === "string_pair_list" ? (
<InputVertical
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useMemo } from "react";
import { useField } from "formik";
import { InputMultiComboBoxField } from "@opal/form";
import type { SelectOption } from "@opal/components/inputs/dropdowns/types";
import TextListField from "@/refresh-components/form/TextListField";
import { useFindingOptions } from "@/lib/connectors/checks/hooks";
import type { StoredReportScope } from "@/lib/connectors/checks/findings";
import type { CheckFindingKey } from "@/lib/connectors/checks/types";
import type { ConfigurableSources } from "@/lib/connectors/types/source";
interface FindingListFieldProps {
name: string;
connector: ConfigurableSources;
/** The discovery finding that lists the options. */
findingKey: CheckFindingKey;
/** The stored reports that supply the options before a run. */
reportScope: StoredReportScope | null;
/** Typed entries only, as in a regex mode: a plain list of the labels. */
plain: boolean;
placeholder: string;
plainPlaceholder: string;
disabled: boolean;
}
/** List field fed by a discovery listing: a pick stores the value and shows the label. Free text still commits. */
export default function FindingListField({
name,
connector,
findingKey,
reportScope,
plain,
placeholder,
plainPlaceholder,
disabled,
}: FindingListFieldProps) {
const findingOptions = useFindingOptions(connector, findingKey, reportScope);
const [field, , helpers] = useField<string[]>(name);
const options = useMemo<SelectOption[]>(
() =>
findingOptions.map((option) => ({
value: option.value,
title: option.label,
description: option.detail ?? undefined,
// A search by the stored value finds the row too.
keywords: [option.value],
})),
[findingOptions]
);
// A plain list takes names, so a picked id becomes its label.
useEffect(() => {
if (!plain) return;
const labels = new Map(
findingOptions.map((option) => [option.value, option.label])
);
const entries: string[] = field.value ?? [];
if (!entries.some((entry) => labels.has(entry))) return;
void helpers.setValue(entries.map((entry) => labels.get(entry) ?? entry));
}, [plain, findingOptions, field.value, helpers]);
if (plain) {
return (
<TextListField
name={name}
placeholder={plainPlaceholder}
disabled={disabled}
/>
);
}
return (
<InputMultiComboBoxField
name={name}
mode="open"
options={options}
placeholder={placeholder}
disabled={disabled}
/>
);
}
@@ -6,6 +6,7 @@ import ConnectorConfigFields from "@/views/admin/connectors/AddConnectorPage/for
import type { ConnectionConfiguration } from "@/lib/connectors/types";
import type { ConfigurableSources } from "@/lib/connectors/types/source";
import type { CredentialValues } from "@/lib/credentials/types";
import type { StoredReportScope } from "@/lib/connectors/checks/findings";
interface ConnectorContentSectionProps {
/** The connector's fields that are not bound to the credential. */
@@ -13,6 +14,8 @@ interface ConnectorContentSectionProps {
values: Record<string, unknown>;
connector: ConfigurableSources;
currentCredential: CredentialValues | null;
/** The stored check reports that can list a field's options. */
reportScope: StoredReportScope | null;
/** Freezes the section while the page locks the configuration. */
disabled?: boolean;
/** Extra content below the fields, such as an edit's file list. */
@@ -25,6 +28,7 @@ export default function ConnectorContentSection({
values,
connector,
currentCredential,
reportScope,
disabled,
children,
}: ConnectorContentSectionProps) {
@@ -43,6 +47,7 @@ export default function ConnectorContentSection({
config={config}
connector={connector}
currentCredential={currentCredential}
reportScope={reportScope}
/>
{children}
</Section>
@@ -1,9 +1,16 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { ValidSources } from "@/lib/connectors/types/source";
import { connectorConfigs } from "@/lib/connectors/connectors";
import { isEditableSource } from "@/lib/connectors/edit/editable";
import {
labelFindingValues,
storedCheckResults,
} from "@/lib/connectors/checks/findings";
import { useCredentialCheckReports } from "@/lib/credentials/hooks";
import { Section } from "@/layouts/general-layouts";
import Text from "@/refresh-components/texts/Text";
import { Button, Divider } from "@opal/components";
@@ -27,7 +34,7 @@ function convertObjectToString(obj: any): string | any {
return obj;
}
export function buildConfigEntries(
function buildConfigEntries(
obj: any,
sourceType: ValidSources
): { [key: string]: string } {
@@ -246,7 +253,32 @@ export function AdvancedConfigDisplay({
);
}
export function ConfigDisplay({
interface ConnectorConfigDisplayProps {
source: ValidSources;
config: Record<string, unknown>;
credentialId: number;
connectorId: number;
}
/** A pair's config with listing-backed entries shown by name. The pair's report is read before the credential's. */
export function ConnectorConfigDisplay({
source,
config,
credentialId,
connectorId,
}: ConnectorConfigDisplayProps) {
const { reportFor } = useCredentialCheckReports(source);
const configEntries = useMemo(() => {
const checks = storedCheckResults(reportFor, { credentialId, connectorId });
const labeled = isEditableSource(source)
? labelFindingValues(connectorConfigs[source], config, checks)
: config;
return buildConfigEntries(labeled, source);
}, [source, config, credentialId, connectorId, reportFor]);
return <ConfigDisplay configEntries={configEntries} />;
}
function ConfigDisplay({
configEntries,
onEdit,
}: {
@@ -20,11 +20,7 @@ import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState, use } from "react";
import useSWR, { mutate } from "swr";
import {
AdvancedConfigDisplay,
buildConfigEntries,
ConfigDisplay,
} from "./ConfigDisplay";
import { AdvancedConfigDisplay, ConnectorConfigDisplay } from "./ConfigDisplay";
import DeletionErrorStatus from "./DeletionErrorStatus";
import { IndexAttemptsTable } from "./IndexAttemptsTable";
import InlineFileManagement from "./InlineFileManagement";
@@ -734,11 +730,11 @@ function Main({ ccPairId }: { ccPairId: number }) {
</Title>
<Card className="px-8 py-4">
<ConfigDisplay
configEntries={buildConfigEntries(
ccPair.connector.connector_specific_config,
ccPair.connector.source
)}
<ConnectorConfigDisplay
source={ccPair.connector.source}
config={ccPair.connector.connector_specific_config}
credentialId={ccPair.credential.id}
connectorId={ccPair.connector.id}
/>
{/* Inline file management for file connectors */}
+1
View File
@@ -30,6 +30,7 @@ function signInCheck(state: DraftCheckState["state"]): DraftCheckState {
duration_ms: state === "passed" ? 1 : null,
from_cache: false,
validates_binding: true,
findings: [],
};
}