Skip to content

Commit 8a480af

Browse files
authored
Added support for Admitions and code copy button in serviceDoc panel (#27732)
* Added support for Admitions and code copy button in serviceDoc panel * Addressed gitar comment * lint fixes * lint fix * fixed all the .md files issues * addressed gitar comment * added playwright test * addressed gitar comment * fix lint issue * addressed PR comment * lint fix
1 parent 46390da commit 8a480af

31 files changed

Lines changed: 853 additions & 104 deletions
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* Copyright 2025 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
import { expect, Page, test } from '@playwright/test';
15+
import { redirectToHomePage } from '../../utils/common';
16+
import { waitForAllLoadersToDisappear } from '../../utils/entity';
17+
18+
test.use({ storageState: 'playwright/.auth/admin.json' });
19+
20+
/**
21+
* Navigates to MySQL service creation step 3 (configure connection),
22+
* where the ServiceDocPanel is visible with code blocks and sections.
23+
*/
24+
const goToMysqlConnectionStep = async (page: Page, serviceName: string) => {
25+
await page.goto('/databaseServices/add-service', {
26+
waitUntil: 'domcontentloaded',
27+
});
28+
await waitForAllLoadersToDisappear(page);
29+
await page.getByTestId('Mysql').click();
30+
await page.getByTestId('next-button').click();
31+
await page.getByTestId('service-name').fill(serviceName);
32+
await page.getByTestId('next-button').click();
33+
await page.getByTestId('service-requirements').waitFor({ state: 'visible' });
34+
};
35+
36+
test.describe('ServiceDocPanel', () => {
37+
test.beforeEach(async ({ page }) => {
38+
await redirectToHomePage(page);
39+
});
40+
41+
test.describe('Content rendering', () => {
42+
test('should render headings not raw markdown', async ({ page }) => {
43+
await goToMysqlConnectionStep(page, 'pw-doc-panel-headings');
44+
45+
const docPanel = page.getByTestId('service-requirements');
46+
47+
// Requirements h2 heading should render as an element, not raw "## Requirements"
48+
await expect(docPanel.locator('h2').first()).toBeVisible();
49+
await expect(docPanel).not.toContainText('## Requirements');
50+
});
51+
52+
test('should render admonition blocks with correct class', async ({
53+
page,
54+
}) => {
55+
await goToMysqlConnectionStep(page, 'pw-doc-panel-admonition');
56+
57+
const docPanel = page.getByTestId('service-requirements');
58+
59+
// Mysql.md has $$note blocks — should render as .admonition.admonition-note
60+
const admonition = docPanel.locator('.admonition-note').first();
61+
62+
await expect(admonition).toBeVisible();
63+
// Should contain actual note content, not raw "$$note" syntax
64+
await expect(docPanel).not.toContainText('$$note');
65+
});
66+
67+
test('should render code blocks inside pre > code, not as raw text', async ({
68+
page,
69+
}) => {
70+
await goToMysqlConnectionStep(page, 'pw-doc-panel-codeblock');
71+
72+
const docPanel = page.getByTestId('service-requirements');
73+
74+
await expect(docPanel.locator('pre code').first()).toBeVisible();
75+
// Raw fence markers should not appear
76+
await expect(docPanel).not.toContainText('```');
77+
});
78+
79+
test('should render links that open in a new tab', async ({ page }) => {
80+
await goToMysqlConnectionStep(page, 'pw-doc-panel-links');
81+
82+
const docPanel = page.getByTestId('service-requirements');
83+
const externalLink = docPanel.locator('a[target="_blank"]').first();
84+
85+
await expect(externalLink).toBeVisible();
86+
await expect(externalLink).toHaveAttribute('href', /^https?:\/\//);
87+
});
88+
89+
test('should render image in Mssql doc panel', async ({ page }) => {
90+
await page.goto('/databaseServices/add-service', {
91+
waitUntil: 'domcontentloaded',
92+
});
93+
await waitForAllLoadersToDisappear(page);
94+
await page.getByTestId('Mssql').click();
95+
await page.getByTestId('next-button').click();
96+
await page.getByTestId('service-name').fill('pw-doc-panel-mssql-img');
97+
await page.getByTestId('next-button').click();
98+
await page.getByTestId('service-requirements').waitFor({
99+
state: 'visible',
100+
});
101+
102+
const docPanel = page.getByTestId('service-requirements');
103+
const image = docPanel.locator('img').first();
104+
105+
await expect(image).toBeVisible();
106+
// Verify the image loaded successfully (no broken image)
107+
const naturalWidth = await image.evaluate(
108+
(img: HTMLImageElement) => img.naturalWidth
109+
);
110+
111+
expect(naturalWidth).toBeGreaterThan(0);
112+
});
113+
});
114+
115+
test.describe('Section highlighting', () => {
116+
test('should highlight section when the corresponding form field is focused', async ({
117+
page,
118+
}) => {
119+
await goToMysqlConnectionStep(page, 'pw-doc-panel-highlight');
120+
121+
const docPanel = page.getByTestId('service-requirements');
122+
123+
// No section should be highlighted initially
124+
await expect(
125+
docPanel.locator('section[data-highlighted="true"]')
126+
).toHaveCount(0);
127+
128+
// Focus the username field — activeField becomes "username"
129+
await page.locator(String.raw`#root\/username`).focus();
130+
131+
// The username section should now be highlighted
132+
const usernameSection = docPanel.locator(
133+
'section[data-id="username"][data-highlighted="true"]'
134+
);
135+
136+
await expect(usernameSection).toBeVisible();
137+
});
138+
139+
test('should remove highlight from previous section when a new field is focused', async ({
140+
page,
141+
}) => {
142+
await goToMysqlConnectionStep(page, 'pw-doc-panel-highlight-switch');
143+
144+
const docPanel = page.getByTestId('service-requirements');
145+
146+
// Focus username first
147+
await page.locator(String.raw`#root\/username`).focus();
148+
149+
await expect(
150+
docPanel.locator('section[data-id="username"][data-highlighted="true"]')
151+
).toBeVisible();
152+
153+
// Focus hostPort — username section should lose highlight
154+
await page.locator(String.raw`#root\/hostPort`).focus();
155+
156+
await expect(
157+
docPanel.locator('section[data-id="username"][data-highlighted="true"]')
158+
).toHaveCount(0);
159+
160+
// hostPort section should now be highlighted
161+
await expect(
162+
docPanel.locator('section[data-id="hostPort"][data-highlighted="true"]')
163+
).toBeVisible();
164+
});
165+
166+
test('should only ever have one section highlighted at a time', async ({
167+
page,
168+
}) => {
169+
await goToMysqlConnectionStep(page, 'pw-doc-panel-single-highlight');
170+
171+
const docPanel = page.getByTestId('service-requirements');
172+
173+
await page.locator(String.raw`#root\/username`).focus();
174+
await page.locator(String.raw`#root\/hostPort`).focus();
175+
176+
await expect(
177+
docPanel.locator('section[data-highlighted="true"]')
178+
).toHaveCount(1);
179+
});
180+
181+
test('should load the correct doc file for the selected service type', async ({
182+
page,
183+
}) => {
184+
await goToMysqlConnectionStep(page, 'pw-doc-panel-correct-doc');
185+
186+
const docPanel = page.getByTestId('service-requirements');
187+
188+
// MySQL doc starts with "# MySQL"
189+
await expect(docPanel.locator('h1').first()).toContainText('MySQL');
190+
});
191+
});
192+
193+
test.describe('Code block copy button', () => {
194+
test('should copy code block content to clipboard and show copied tooltip', async ({
195+
page,
196+
context,
197+
}) => {
198+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
199+
await goToMysqlConnectionStep(page, 'pw-doc-panel-copy');
200+
201+
const docPanel = page.getByTestId('service-requirements');
202+
const codeBlock = docPanel.locator('pre').first();
203+
const copyButtonWrapper = docPanel.locator('.code-copy-button').first();
204+
const copyButton = docPanel.getByTestId('code-block-copy-icon').first();
205+
206+
// Hover code block to reveal the button
207+
await codeBlock.hover();
208+
await expect(copyButton).toBeVisible();
209+
210+
// Verify initial state
211+
await expect(copyButtonWrapper).toHaveAttribute('data-copied', 'false');
212+
213+
// Click and verify copied state + tooltip
214+
await copyButton.click();
215+
216+
await expect(copyButtonWrapper).toHaveAttribute('data-copied', 'true');
217+
await expect(page.getByRole('tooltip')).toBeVisible();
218+
219+
// Verify clipboard is non-empty
220+
const clipboardText = await page.evaluate(() =>
221+
navigator.clipboard.readText()
222+
);
223+
224+
expect(clipboardText.length).toBeGreaterThan(0);
225+
226+
// Verify state resets after 2s timer
227+
await expect(copyButtonWrapper).toHaveAttribute('data-copied', 'false');
228+
});
229+
});
230+
});

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/CustomDashboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ $$section
1414
Source Python Class Name to instantiated by the ingestion workflow.
1515
1616
Note that it should implement the `next_record` method so that the Workflow can keep reading and sending records to the OpenMetadata API.
17+
$$
1718

1819
$$section
1920
### Connection Options $(id="connectionOptions")

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/Looker.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,21 @@ $$
4747

4848
If we choose to inform the GitHub credentials to ingest LookML Views:
4949

50+
$$section
5051
#### Repository Owner $(id="repositoryOwner")
5152
5253
The owner (user or organization) of a GitHub repository. For example, in https://github.com/open-metadata/OpenMetadata, the owner is `open-metadata`.
5354
55+
$$
56+
57+
$$section
5458
#### Repository Name $(id="repositoryName")
5559
5660
The name of a GitHub repository. For example, in https://github.com/open-metadata/OpenMetadata, the name is `OpenMetadata`.
5761
62+
$$
63+
64+
$$section
5865
#### API Token $(id="token")
5966
6067
Token to use the API. This is required for private repositories and to ensure we don't hit API limits.
@@ -72,3 +79,5 @@ If your GitHub organization has SAML Single Sign-On (SSO) enabled, you must auth
7279
Follow these <a href="https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/authorizing-a-personal-access-token-for-use-with-single-sign-on" target="_blank">steps</a> to authorize your token for use with SAML SSO.
7380
7481
$$
82+
83+
$$

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/Athena.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,9 @@ And is defined as:
4848
```
4949

5050

51-
{% note %}
52-
51+
$$note
5352
If you have external services other than glue and facing permission issues, add the permissions to the list above.
54-
55-
{% /note %}
53+
$$
5654

5755

5856
You can find further information on the Athena connector in the <a href="https://docs.open-metadata.org/connectors/database/athena" target="_blank">docs</a>.

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/CustomDatabase.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ $$section
1414
Source Python Class Name to instantiated by the ingestion workflow.
1515
1616
Note that it should implement the `next_record` method so that the Workflow can keep reading and sending records to the OpenMetadata API.
17+
$$
1718

1819
$$section
1920
### Connection Options $(id="connectionOptions")

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/Databricks.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ To extract basic metadata (catalogs, schemas, tables, views) from Databricks, th
1616

1717
```sql
1818
-- Grant USE CATALOG on catalog
19-
GRANT USE CATALOG ON CATALOG <catalog_name> TO `<user_or_service_principal>`;
19+
GRANT USE CATALOG ON CATALOG <catalog_name> TO '<user_or_service_principal>';
2020

2121
-- Grant USE SCHEMA on schemas
22-
GRANT USE SCHEMA ON SCHEMA <schema_name> TO `<user_or_service_principal>`;
22+
GRANT USE SCHEMA ON SCHEMA <schema_name> TO '<user_or_service_principal>';
2323

2424
-- Grant SELECT on tables and views
25-
GRANT SELECT ON TABLE <table_name> TO `<user_or_service_principal>`;
25+
GRANT SELECT ON TABLE <table_name> TO '<user_or_service_principal>';
2626
```
2727

2828
### View Definitions (Optional)
@@ -31,7 +31,7 @@ To extract view definitions from `INFORMATION_SCHEMA.VIEWS`, ensure the user has
3131

3232
```sql
3333
-- Grant SELECT on INFORMATION_SCHEMA.VIEWS
34-
GRANT SELECT ON VIEW information_schema.views TO `<user_or_service_principal>`;
34+
GRANT SELECT ON VIEW information_schema.views TO '<user_or_service_principal>';
3535
```
3636

3737
### Unity Catalog Tags (Optional)
@@ -40,16 +40,16 @@ To extract tags at different levels (catalog, schema, table, column), the user n
4040

4141
```sql
4242
-- For catalog-level tags
43-
GRANT SELECT ON TABLE system.information_schema.catalog_tags TO `<user_or_service_principal>`;
43+
GRANT SELECT ON TABLE system.information_schema.catalog_tags TO '<user_or_service_principal>';
4444

4545
-- For schema-level tags
46-
GRANT SELECT ON TABLE system.information_schema.schema_tags TO `<user_or_service_principal>`;
46+
GRANT SELECT ON TABLE system.information_schema.schema_tags TO '<user_or_service_principal>';
4747

4848
-- For table-level tags
49-
GRANT SELECT ON TABLE system.information_schema.table_tags TO `<user_or_service_principal>`;
49+
GRANT SELECT ON TABLE system.information_schema.table_tags TO '<user_or_service_principal>';
5050

5151
-- For column-level tags
52-
GRANT SELECT ON TABLE system.information_schema.column_tags TO `<user_or_service_principal>`;
52+
GRANT SELECT ON TABLE system.information_schema.column_tags TO '<user_or_service_principal>';
5353
```
5454

5555
$$note
@@ -62,10 +62,10 @@ To extract table and column-level lineage from Unity Catalog system tables, the
6262

6363
```sql
6464
-- For table lineage
65-
GRANT SELECT ON TABLE system.access.table_lineage TO `<user_or_service_principal>`;
65+
GRANT SELECT ON TABLE system.access.table_lineage TO '<user_or_service_principal>';
6666

6767
-- For column lineage
68-
GRANT SELECT ON TABLE system.access.column_lineage TO `<user_or_service_principal>`;
68+
GRANT SELECT ON TABLE system.access.column_lineage TO '<user_or_service_principal>';
6969
```
7070

7171
$$note

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/DeltaLake.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,28 +70,28 @@ $$section
7070
7171
In this configuration we will be pointing to the Hive Metastore database directly.
7272
73-
#### Hive Metastore Database ($id="metastoreDb")
73+
### Hive Metastore Database
7474
7575
JDBC connection to the metastore database.
7676
7777
It should be a properly formatted database URL, which will be used in the Spark Configuration under `spark.hadoop.javax.jdo.option.ConnectionURL`.
7878
79-
#### Connection UserName ($id="username")
79+
#### Connection UserName
8080
8181
Username to use against the metastore database. The value will be used in the Spark Configuration under `spark.hadoop.javax.jdo.option.ConnectionUserName`.
8282
83-
#### Connection Password ($id="password")
83+
#### Connection Password
8484
8585
Password to use against metastore database. The value will be used in the Spark Configuration under `spark.hadoop.javax.jdo.option.ConnectionPassword`.
8686
87-
#### Connection Driver Name ($id="driverName")
87+
#### Connection Driver Name
8888
8989
Driver class name for JDBC metastore. The value will be used in the Spark Configuration under `spark.hadoop.javax.jdo.option.ConnectionDriverName`,
9090
e.g., `org.mariadb.jdbc.Driver`.
9191
9292
You will need to provide the driver to the ingestion image, and pass the Class path as explained below.
9393
94-
#### JDBC Driver Class Path ($id="jdbcDriverClassPath")
94+
#### JDBC Driver Class Path
9595
9696
Class path to JDBC driver required for the JDBC connection. The value will be used in the Spark Configuration under `spark.driver.extraClassPath`.
9797

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/Doris.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,4 @@ $$
7474
$$section
7575
### Connection Arguments $(id="connectionArguments")
7676
Additional connection arguments such as security or protocol configs that can be sent to the service during connection.
77+
$$

openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/Exasol.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,5 @@ Uses Transport Layer Security (TLS) but disables the validation of the server ce
4747
#### disable-tls
4848
Does not use any Transport Layer Security (TLS). Data will be sent in plain text (no encryption).
4949
While this may be helpful in rare cases of debugging, make sure you do not use this in production.
50+
$$
5051

0 commit comments

Comments
 (0)