authHeaders) {
+ assertEquals(expected.getName(), updated.getName());
+ assertEquals(expected.getFullyQualifiedName(), updated.getFullyQualifiedName());
+ assertEquals(expected.getDescription(), updated.getDescription());
+ assertEquals(expected.getDisplayName(), updated.getDisplayName());
+ assertEquals(expected.getProvider(), updated.getProvider());
+ assertEquals(expected.getTemplateBody(), updated.getTemplateBody());
+ }
+
+ @Override
+ public void assertFieldChange(String fieldName, Object expected, Object actual) {
+ if (expected == actual) {
+ return;
+ }
+ switch (fieldName) {
+ case "templateBody":
+ case "description":
+ case "displayName":
+ assertEquals(expected, actual);
+ break;
+ default:
+ assertCommonFieldChange(fieldName, expected, actual);
+ break;
+ }
+ }
+
+ @Override
+ public NotificationTemplate validateGetWithDifferentFields(
+ NotificationTemplate template, boolean byName) throws HttpResponseException {
+ String fields = "";
+ template =
+ byName
+ ? getEntityByName(template.getFullyQualifiedName(), fields, ADMIN_AUTH_HEADERS)
+ : getEntity(template.getId(), fields, ADMIN_AUTH_HEADERS);
+ assertNotNull(template.getName());
+ assertNotNull(template.getFullyQualifiedName());
+ assertNotNull(template.getDescription());
+ assertNotNull(template.getProvider());
+ assertNotNull(template.getTemplateBody());
+ return template;
+ }
+
+ @Test
+ void post_validNotificationTemplate_200(TestInfo test) throws IOException {
+ CreateNotificationTemplate create =
+ createRequest(getEntityName(test))
+ .withTemplateBody(
+ "Pipeline {{entity.name}} Status Update
"
+ + "Status: {{entity.pipelineStatus}}
");
+ NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
+
+ assertEquals(getEntityName(test), template.getFullyQualifiedName().replaceAll("^\"|\"$", ""));
+ assertEquals(ProviderType.USER, template.getProvider());
+ }
+
+ @Test
+ void post_invalidHandlebarsTemplate_400(TestInfo test) {
+ CreateNotificationTemplate create =
+ createRequest(getEntityName(test)).withTemplateBody("{{#if entity.name}} Missing end if");
+
+ assertResponse(
+ () -> createEntity(create, ADMIN_AUTH_HEADERS), BAD_REQUEST, "Invalid template syntax");
+ }
+
+ @Test
+ void post_duplicateNotificationTemplate_409(TestInfo test) throws IOException {
+ CreateNotificationTemplate create = createRequest(getEntityName(test));
+ createEntity(create, ADMIN_AUTH_HEADERS);
+
+ assertResponse(
+ () -> createEntity(create, ADMIN_AUTH_HEADERS), CONFLICT, "Entity already exists");
+ }
+
+ @Test
+ void patch_notificationTemplateAttributes_200(TestInfo test) throws IOException {
+ NotificationTemplate template =
+ createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS);
+
+ String origTemplateBody = template.getTemplateBody();
+ String origDescription = template.getDescription();
+ String origDisplayName = template.getDisplayName();
+
+ String newTemplateBody = "{{entity.name}} - Updated Template
";
+ String newDescription = "Updated description";
+ String newDisplayName = "Updated Display Name";
+
+ String json =
+ String.format(
+ "[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]",
+ JsonUtils.pojoToJson(newTemplateBody));
+ template = patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS);
+ assertEquals(newTemplateBody, template.getTemplateBody());
+
+ String json2 =
+ String.format(
+ "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":%s}]",
+ JsonUtils.pojoToJson(newDescription));
+ template = patchEntity(template.getId(), JsonUtils.readTree(json2), ADMIN_AUTH_HEADERS);
+ assertEquals(newDescription, template.getDescription());
+
+ String json3 =
+ String.format(
+ "[{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":%s}]",
+ JsonUtils.pojoToJson(newDisplayName));
+ template = patchEntity(template.getId(), JsonUtils.readTree(json3), ADMIN_AUTH_HEADERS);
+ assertEquals(newDisplayName, template.getDisplayName());
+
+ ChangeDescription change = getChangeDescription(template, MINOR_UPDATE);
+ fieldUpdated(change, "templateBody", origTemplateBody, newTemplateBody);
+ fieldUpdated(change, "description", origDescription, newDescription);
+ fieldUpdated(change, "displayName", origDisplayName, newDisplayName);
+ }
+
+ @Test
+ void patch_invalidTemplateBody_400(TestInfo test) throws IOException {
+ NotificationTemplate template =
+ createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS);
+
+ String invalidTemplateBody = "{{#each items}} Missing end each";
+ assertResponse(
+ () -> {
+ String json =
+ String.format(
+ "[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]",
+ JsonUtils.pojoToJson(invalidTemplateBody));
+ patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS);
+ },
+ BAD_REQUEST,
+ "Invalid template syntax");
+ }
+
+ @Test
+ void put_updateNotificationTemplate_200(TestInfo test) throws IOException {
+ CreateNotificationTemplate request = createRequest(getEntityName(test));
+ NotificationTemplate template = createEntity(request, ADMIN_AUTH_HEADERS);
+
+ String newTemplateBody = "Updated: {{entity.name}}
";
+ String newDescription = "Updated via PUT";
+
+ CreateNotificationTemplate updateRequest =
+ createRequest(template.getName())
+ .withDisplayName(template.getDisplayName())
+ .withDescription(newDescription)
+ .withTemplateBody(newTemplateBody);
+
+ NotificationTemplate updatedTemplate = updateEntity(updateRequest, OK, ADMIN_AUTH_HEADERS);
+ assertEquals(newTemplateBody, updatedTemplate.getTemplateBody());
+ assertEquals(newDescription, updatedTemplate.getDescription());
+ assertEquals(template.getId(), updatedTemplate.getId());
+
+ ChangeDescription change = getChangeDescription(updatedTemplate, MINOR_UPDATE);
+ fieldUpdated(change, "templateBody", template.getTemplateBody(), newTemplateBody);
+ fieldUpdated(change, "description", template.getDescription(), newDescription);
+ }
+
+ @Test
+ void get_notificationTemplateByFQN_200(TestInfo test) throws IOException {
+ CreateNotificationTemplate create = createRequest(getEntityName(test));
+ NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
+
+ // Use the actual FQN from the created template for fetching
+ NotificationTemplate fetched =
+ getEntityByName(template.getFullyQualifiedName(), ADMIN_AUTH_HEADERS);
+ assertEquals(template.getId(), fetched.getId());
+ assertEquals(template.getFullyQualifiedName(), fetched.getFullyQualifiedName());
+ }
+
+ @Test
+ void test_multipleTemplates(TestInfo test) throws IOException {
+ // Test creating multiple templates
+ String[] templateNames = {"entity_change", "test_change", "custom_alert"};
+
+ for (String templateName : templateNames) {
+ String name = getEntityName(test) + "_" + templateName;
+ CreateNotificationTemplate create =
+ createRequest(name).withTemplateBody("Template for {{entity.name}}
");
+
+ NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
+ assertEquals(name, template.getFullyQualifiedName().replaceAll("^\"|\"$", ""));
+ assertEquals(ProviderType.USER, template.getProvider());
+ }
+ }
+
+ @Test
+ void test_templateValidationWithComplexHandlebars(TestInfo test) throws IOException {
+ String complexTemplate =
+ "{{#if entity.owner}}"
+ + "Owner: {{entity.owner.name}}
"
+ + "{{else}}"
+ + "No owner assigned
"
+ + "{{/if}}"
+ + "{{#each entity.tags as |tag|}}"
+ + "{{tag.tagFQN}}"
+ + "{{/each}}";
+
+ CreateNotificationTemplate create =
+ createRequest(getEntityName(test)).withTemplateBody(complexTemplate);
+
+ NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
+ assertEquals(complexTemplate, template.getTemplateBody());
+ }
+
+ @Test
+ void test_listFilterByProvider(TestInfo test) throws IOException {
+ // Create multiple templates with USER provider
+ String userTemplate1 = getEntityName(test) + "_user1";
+ String userTemplate2 = getEntityName(test) + "_user2";
+
+ CreateNotificationTemplate create1 =
+ createRequest(userTemplate1).withTemplateBody("User template 1
");
+ CreateNotificationTemplate create2 =
+ createRequest(userTemplate2).withTemplateBody("User template 2
");
+
+ NotificationTemplate template1 = createEntity(create1, ADMIN_AUTH_HEADERS);
+ NotificationTemplate template2 = createEntity(create2, ADMIN_AUTH_HEADERS);
+
+ // Verify both templates have USER provider
+ assertEquals(ProviderType.USER, template1.getProvider());
+ assertEquals(ProviderType.USER, template2.getProvider());
+
+ // List all templates (no filter)
+ Map params = new HashMap<>();
+ ResultList allTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
+ assertTrue(allTemplates.getData().size() >= 2);
+
+ // List only USER templates (use lowercase value as stored in JSON)
+ params.put("provider", ProviderType.USER.value());
+ params.put("limit", "1000"); // Increase limit to ensure we get all templates
+ ResultList userTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
+
+ // Verify all returned templates are USER provider
+ for (NotificationTemplate template : userTemplates.getData()) {
+ assertEquals(ProviderType.USER, template.getProvider());
+ }
+
+ // Verify our created templates are in the results
+ boolean found1 =
+ userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId()));
+ boolean found2 =
+ userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId()));
+
+ assertTrue(found1, "Template1 should be in USER filtered results");
+ assertTrue(found2, "Template2 should be in USER filtered results");
+
+ // List only SYSTEM templates (use lowercase value as stored in JSON)
+ params.put("provider", "system");
+ ResultList systemTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
+
+ // Verify all returned templates are SYSTEM provider
+ for (NotificationTemplate template : systemTemplates.getData()) {
+ assertEquals(ProviderType.SYSTEM, template.getProvider());
+ }
+
+ // Verify our USER templates are NOT in SYSTEM results
+ assertFalse(
+ systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId())));
+ assertFalse(
+ systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId())));
+ }
+}
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json b/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json
new file mode 100644
index 00000000000..46eef4e0bb3
--- /dev/null
+++ b/openmetadata-spec/src/main/resources/json/schema/api/events/createNotificationTemplate.json
@@ -0,0 +1,43 @@
+{
+ "$id": "https://open-metadata.org/schema/api/events/createNotificationTemplate.json",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "CreateNotificationTemplate",
+ "description": "Create request for Notification Template",
+ "type": "object",
+ "javaType": "org.openmetadata.schema.api.events.CreateNotificationTemplate",
+ "javaInterfaces": ["org.openmetadata.schema.CreateEntity"],
+ "properties": {
+ "name": {
+ "description": "Name that uniquely identifies this notification template (e.g., 'entity_change', 'test_change')",
+ "$ref": "../../type/basic.json#/definitions/entityName"
+ },
+ "displayName": {
+ "description": "Display name for this notification template",
+ "type": "string"
+ },
+ "description": {
+ "description": "Description of this notification template",
+ "$ref": "../../type/basic.json#/definitions/markdown"
+ },
+ "templateBody": {
+ "description": "Handlebars template content for rendering notifications",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 10240
+ },
+ "owners": {
+ "description": "Owners of this template",
+ "$ref": "../../type/entityReferenceList.json",
+ "default": null
+ },
+ "domains": {
+ "description": "Fully qualified names of the domains the template belongs to",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": ["name", "templateBody"],
+ "additionalProperties": false
+}
\ No newline at end of file
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json b/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json
new file mode 100644
index 00000000000..798c2263d0e
--- /dev/null
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/events/notificationTemplate.json
@@ -0,0 +1,79 @@
+{
+ "$id": "https://open-metadata.org/schema/entity/events/notificationTemplate.json",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "NotificationTemplate",
+ "$comment": "@om-entity-type",
+ "description": "A NotificationTemplate defines the default formatting template for notifications of a specific entity type.",
+ "type": "object",
+ "javaType": "org.openmetadata.schema.entity.events.NotificationTemplate",
+ "javaInterfaces": [
+ "org.openmetadata.schema.EntityInterface"
+ ],
+ "properties": {
+ "id": {
+ "description": "Unique identifier of this template instance.",
+ "$ref": "../../type/basic.json#/definitions/uuid"
+ },
+ "name": {
+ "description": "Name for the notification template (e.g., 'Default Table Template', 'Custom Pipeline Alerts').",
+ "$ref": "../../type/basic.json#/definitions/entityName"
+ },
+ "displayName": {
+ "description": "Display Name that identifies this template.",
+ "type": "string"
+ },
+ "fullyQualifiedName": {
+ "description": "Fully qualified name for the template.",
+ "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName"
+ },
+ "description": {
+ "description": "Description of the template purpose and usage.",
+ "$ref": "../../type/basic.json#/definitions/markdown"
+ },
+ "version": {
+ "description": "Metadata version of the template.",
+ "$ref": "../../type/entityHistory.json#/definitions/entityVersion"
+ },
+ "updatedAt": {
+ "description": "Last update time corresponding to the new version of the template.",
+ "$ref": "../../type/basic.json#/definitions/timestamp"
+ },
+ "updatedBy": {
+ "description": "User who made the update.",
+ "type": "string"
+ },
+ "href": {
+ "description": "Link to this template resource.",
+ "$ref": "../../type/basic.json#/definitions/href"
+ },
+ "templateBody": {
+ "description": "Handlebars HTML template body with placeholders.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 10240
+ },
+ "provider": {
+ "description": "Provider of the template. System templates are pre-loaded and cannot be deleted. User templates are created by users and can be deleted.",
+ "$ref": "../../type/basic.json#/definitions/providerType"
+ },
+ "changeDescription": {
+ "description": "Change that lead to this version of the template.",
+ "$ref": "../../type/entityHistory.json#/definitions/changeDescription"
+ },
+ "incrementalChangeDescription": {
+ "description": "Change that lead to this version of the entity.",
+ "$ref": "../../type/entityHistory.json#/definitions/changeDescription"
+ },
+ "deleted": {
+ "description": "When `true` indicates the template has been soft deleted.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "templateBody"
+ ],
+ "additionalProperties": false
+}
\ No newline at end of file
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts
index 73c63a60944..0a6c794bef6 100644
--- a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts
+++ b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts
@@ -44,7 +44,7 @@ export default defineConfig({
}
},
sourcemap: true,
- minify: 'terser',
+ minify: 'esbuild',
target: 'es2020'
},
resolve: {
diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts
index ada54ffeb68..d9a2a939dcb 100644
--- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts
+++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreSortOrderFilter.spec.ts
@@ -95,7 +95,6 @@ test.describe('Explore Sort Order Filter', () => {
await page.getByTestId('update-btn').click();
await selectSortOrder(page, 'Name');
- await page.waitForLoadState('networkidle');
await verifyEntitiesAreSorted(page);
const clearFilters = page.getByTestId('clear-filters');
diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts
index 0188fc9fe12..67a52e60723 100644
--- a/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts
+++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/explore.ts
@@ -257,31 +257,42 @@ export const selectSortOrder = async (page: Page, sortOrder: string) => {
await page.waitForSelector(`role=menuitem[name="${sortOrder}"]`, {
state: 'visible',
});
+ const nameFilter = page.waitForResponse(
+ `/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=desc*`
+ );
await page.getByRole('menuitem', { name: sortOrder }).click();
+ await nameFilter;
await expect(page.getByTestId('sorting-dropdown-label')).toHaveText(
sortOrder
);
+ const ascSortOrder = page.waitForResponse(
+ `/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=asc*`
+ );
await page.getByTestId('sort-order-button').click();
+ await ascSortOrder;
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
};
export const verifyEntitiesAreSorted = async (page: Page) => {
+ // Wait for search results to be stable after sort
+ await page.waitForSelector('[data-testid="search-results"]', {
+ state: 'visible',
+ });
+ await page.waitForLoadState('networkidle');
+
const entityNames = await page.$$eval(
'[data-testid="search-results"] .explore-search-card [data-testid="entity-link"]',
(elements) => elements.map((el) => el.textContent?.trim() ?? '')
);
- // Normalize for case insensitivity, but retain punctuation
- const normalize = (str: string) => str.toLowerCase().trim();
-
- // Sort using ASCII-based string comparison (ES behavior)
+ // Elasticsearch keyword field with case-insensitive sorting
const sortedEntityNames = [...entityNames].sort((a, b) => {
- const normA = normalize(a);
- const normB = normalize(b);
+ const aLower = a.toLowerCase();
+ const bLower = b.toLowerCase();
- return normA < normB ? -1 : normA > normB ? 1 : 0;
+ return aLower < bLower ? -1 : aLower > bLower ? 1 : 0;
});
expect(entityNames).toEqual(sortedEntityNames);
diff --git a/pom.xml b/pom.xml
index fb02e8fb427..abcf8e3d9ff 100644
--- a/pom.xml
+++ b/pom.xml
@@ -117,6 +117,7 @@
2.21.0
5.9.3
4.0.14
+ 4.3.1
1.5.0
4.13.2
@@ -608,6 +609,11 @@
com.github.java-json-tools
json-patch
${json-patch.version}
+