From 3dcfc379bd7b9f8919c7cde5a00ef7b751597618 Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Thu, 12 Mar 2026 07:36:08 +0700 Subject: [PATCH] feat(rules): add rules management UI Adds list/create/edit pages for ruledef + ruleaction with SET_RESULT actions editor and expression tester, and links it from the sidebar. --- docs/rules.yaml | 316 +++++++++++++++ src/lib/api/rules.js | 108 +++++ src/lib/components/Sidebar.svelte | 94 +++-- src/lib/components/rules/ActionsEditor.svelte | 282 ++++++++++++++ .../components/rules/ExprTesterModal.svelte | 116 ++++++ src/lib/components/rules/RuleForm.svelte | 224 +++++++++++ .../components/rules/TestSiteSearch.svelte | 177 +++++++++ src/lib/types/index.ts | 13 + src/lib/types/rules.types.ts | 52 +++ src/routes/(app)/rules/+page.svelte | 246 ++++++++++++ src/routes/(app)/rules/[ruleId]/+page.svelte | 368 ++++++++++++++++++ src/routes/(app)/rules/new/+page.svelte | 166 ++++++++ 12 files changed, 2119 insertions(+), 43 deletions(-) create mode 100644 docs/rules.yaml create mode 100644 src/lib/api/rules.js create mode 100644 src/lib/components/rules/ActionsEditor.svelte create mode 100644 src/lib/components/rules/ExprTesterModal.svelte create mode 100644 src/lib/components/rules/RuleForm.svelte create mode 100644 src/lib/components/rules/TestSiteSearch.svelte create mode 100644 src/lib/types/rules.types.ts create mode 100644 src/routes/(app)/rules/+page.svelte create mode 100644 src/routes/(app)/rules/[ruleId]/+page.svelte create mode 100644 src/routes/(app)/rules/new/+page.svelte diff --git a/docs/rules.yaml b/docs/rules.yaml new file mode 100644 index 0000000..014a9d5 --- /dev/null +++ b/docs/rules.yaml @@ -0,0 +1,316 @@ +/api/rules: + get: + tags: [Rules] + summary: List rules + security: + - bearerAuth: [] + parameters: + - name: EventCode + in: query + schema: + type: string + description: Filter by event code + - name: Active + in: query + schema: + type: integer + enum: [0, 1] + description: Filter by active flag + - name: ScopeType + in: query + schema: + type: string + enum: [GLOBAL, TESTSITE] + description: Filter by scope type + - name: TestSiteID + in: query + schema: + type: integer + description: Filter by TestSiteID (for TESTSITE scope) + - name: search + in: query + schema: + type: string + description: Search by rule name + responses: + '200': + description: List of rules + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '../components/schemas/rules.yaml#/RuleDef' + + post: + tags: [Rules] + summary: Create rule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: + type: string + Description: + type: string + EventCode: + type: string + example: ORDER_CREATED + ScopeType: + type: string + enum: [GLOBAL, TESTSITE] + TestSiteID: + type: integer + nullable: true + ConditionExpr: + type: string + nullable: true + Priority: + type: integer + Active: + type: integer + enum: [0, 1] + actions: + type: array + items: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: [Name, EventCode, ScopeType] + responses: + '201': + description: Rule created + +/api/rules/{id}: + get: + tags: [Rules] + summary: Get rule (with actions) + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule details + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + $ref: '../components/schemas/rules.yaml#/RuleWithActions' + '404': + description: Rule not found + + patch: + tags: [Rules] + summary: Update rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Name: { type: string } + Description: { type: string } + EventCode: { type: string } + ScopeType: { type: string, enum: [GLOBAL, TESTSITE] } + TestSiteID: { type: integer, nullable: true } + ConditionExpr: { type: string, nullable: true } + Priority: { type: integer } + Active: { type: integer, enum: [0, 1] } + responses: + '200': + description: Rule updated + '404': + description: Rule not found + + delete: + tags: [Rules] + summary: Soft delete rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rule deleted + '404': + description: Rule not found + +/api/rules/validate: + post: + tags: [Rules] + summary: Validate/evaluate an expression + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expr: + type: string + context: + type: object + additionalProperties: true + required: [expr] + responses: + '200': + description: Validation result + +/api/rules/{id}/actions: + get: + tags: [Rules] + summary: List actions for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Actions list + content: + application/json: + schema: + type: object + properties: + status: + type: string + message: + type: string + data: + type: array + items: + $ref: '../components/schemas/rules.yaml#/RuleAction' + + post: + tags: [Rules] + summary: Create action for a rule + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: + type: integer + ActionType: + type: string + ActionParams: + oneOf: + - type: string + - type: object + required: [ActionType] + responses: + '201': + description: Action created + +/api/rules/{id}/actions/{actionId}: + patch: + tags: [Rules] + summary: Update action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + Seq: { type: integer } + ActionType: { type: string } + ActionParams: + oneOf: + - type: string + - type: object + responses: + '200': + description: Action updated + + delete: + tags: [Rules] + summary: Soft delete action + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: actionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Action deleted diff --git a/src/lib/api/rules.js b/src/lib/api/rules.js new file mode 100644 index 0000000..6577ca1 --- /dev/null +++ b/src/lib/api/rules.js @@ -0,0 +1,108 @@ +import { get, post, patch, del } from './client.js'; + +/** + * @typedef {import('$lib/types/rules.types.js').RuleDef} RuleDef + * @typedef {import('$lib/types/rules.types.js').RuleAction} RuleAction + * @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions + * @typedef {import('$lib/types/rules.types.js').RulesListResponse} RulesListResponse + * @typedef {import('$lib/types/rules.types.js').RuleDetailResponse} RuleDetailResponse + * @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse + * @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse + */ + +/** + * List rules + * @param {{ EventCode?: string, Active?: 0|1|number, ScopeType?: string, TestSiteID?: number, search?: string }} [params] + * @returns {Promise} + */ +export async function fetchRules(params = {}) { + const query = new URLSearchParams(params).toString(); + return get(query ? `/api/rules?${query}` : '/api/rules'); +} + +/** + * Get rule (with actions) + * @param {number} id + * @returns {Promise} + */ +export async function fetchRule(id) { + return get(`/api/rules/${id}`); +} + +/** + * Create a rule; optionally include initial actions + * @param {Partial & { actions?: Array> }} payload + * @returns {Promise} + */ +export async function createRule(payload) { + return post('/api/rules', payload); +} + +/** + * Update a rule + * @param {number} id + * @param {Partial} payload + * @returns {Promise} + */ +export async function updateRule(id, payload) { + return patch(`/api/rules/${id}`, payload); +} + +/** + * Soft delete a rule + * @param {number} id + * @returns {Promise} + */ +export async function deleteRule(id) { + return del(`/api/rules/${id}`); +} + +/** + * Validate/evaluate an expression + * @param {string} expr + * @param {Record} [context] + * @returns {Promise} + */ +export async function validateExpression(expr, context = {}) { + return post('/api/rules/validate', { expr, context }); +} + +/** + * List actions for a rule + * @param {number} id + * @returns {Promise} + */ +export async function fetchRuleActions(id) { + return get(`/api/rules/${id}/actions`); +} + +/** + * Create action for a rule + * @param {number} id + * @param {Partial} payload + * @returns {Promise} + */ +export async function createRuleAction(id, payload) { + return post(`/api/rules/${id}/actions`, payload); +} + +/** + * Update action + * @param {number} id + * @param {number} actionId + * @param {Partial} payload + * @returns {Promise} + */ +export async function updateRuleAction(id, actionId, payload) { + return patch(`/api/rules/${id}/actions/${actionId}`, payload); +} + +/** + * Soft delete action + * @param {number} id + * @param {number} actionId + * @returns {Promise} + */ +export async function deleteRuleAction(id, actionId) { + return del(`/api/rules/${id}/actions/${actionId}`); +} diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index b2953e0..c9fc198 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1,5 +1,6 @@ {#if isOpen} - + > {/if} @@ -199,7 +203,9 @@ function toggleLaboratory() { {#if isOpen} Laboratory - + + + {/if} @@ -249,7 +255,9 @@ function toggleLaboratory() { {#if isOpen} Organization - + + + {/if} @@ -279,7 +287,9 @@ function toggleLaboratory() { {#if isOpen} Lab Setup - + + + {/if} @@ -288,6 +298,7 @@ function toggleLaboratory() {
  • Containers
  • Test Definitions
  • Test Mapping
  • +
  • Rules
  • {/if} @@ -303,7 +314,9 @@ function toggleLaboratory() { {#if isOpen} Users & Contacts - + + + {/if} @@ -328,7 +341,9 @@ function toggleLaboratory() { {#if isOpen} Reference Data - + + + {/if} @@ -486,13 +501,6 @@ function toggleLaboratory() { animation: slideDown 0.2s ease-out; } - .submenu.nested { - margin-left: 1rem; - margin-top: 0.25rem; - padding-left: 0.5rem; - border-left: 1px solid hsl(var(--bc) / 0.1); - } - .submenu-link { display: flex; align-items: center; diff --git a/src/lib/components/rules/ActionsEditor.svelte b/src/lib/components/rules/ActionsEditor.svelte new file mode 100644 index 0000000..58617a0 --- /dev/null +++ b/src/lib/components/rules/ActionsEditor.svelte @@ -0,0 +1,282 @@ + + +
    +
    +
    +

    + + Actions +

    +

    SET_RESULT actions executed in sequence

    +
    + +
    + +
    + + + + + + + + + + + + {#if actions.length === 0} + + + + {:else} + {#each actions as action, index (action.RuleActionID ?? index)} + {@const p = ensureParams(action)} + {@const targetMode = getTargetMode(action)} + {@const valueMode = getValueMode(action)} + + + + + + + + {/each} + {/if} + +
    SeqTypeTargetValue
    No actions yet
    + + + + +
    + + + {#if targetMode === 'CODE'} + handleTargetSelect(action, sel)} + placeholder="Search test site..." + disabled={disabled} + /> + {:else} + + {/if} +
    +
    +
    + + + {#if valueMode === 'EXPR'} +
    +
    + + + ExpressionLanguage (ternary) + + +
    + +

    Example: `cond ? a : (cond2 ? b : c)`

    +
    + {:else} + + {/if} +
    +
    + +
    +
    + + {#if errors && errors.length} +
    +
    Some actions have validation errors.
    +
    + {/if} +
    + + diff --git a/src/lib/components/rules/ExprTesterModal.svelte b/src/lib/components/rules/ExprTesterModal.svelte new file mode 100644 index 0000000..b2aa46c --- /dev/null +++ b/src/lib/components/rules/ExprTesterModal.svelte @@ -0,0 +1,116 @@ + + + +
    +
    +
    + + +
    + +
    + +
    + + +

    Use a JSON object; it will be passed as context.

    +
    + + {#if errorMessage} +
    + +
    {errorMessage}
    +
    + {/if} + + {#if result} +
    + {#if result.valid} + + {:else} + + {/if} +
    + {#if result.valid} + Expression is valid. + {:else} + Expression is invalid. + {/if} +
    +
    +
    +
    Response
    +
    {JSON.stringify(result, null, 2)}
    +
    + {/if} +
    +
    diff --git a/src/lib/components/rules/RuleForm.svelte b/src/lib/components/rules/RuleForm.svelte new file mode 100644 index 0000000..90e2897 --- /dev/null +++ b/src/lib/components/rules/RuleForm.svelte @@ -0,0 +1,224 @@ + + +
    +
    +
    +
    Name *
    + + {#if fieldError('Name')} +

    {fieldError('Name')}

    + {/if} +
    + +
    +
    Description
    + + {#if fieldError('Description')} +

    {fieldError('Description')}

    + {/if} +
    + +
    +
    +
    Event
    + +
    + +
    +
    Priority
    + + {#if fieldError('Priority')} +

    {fieldError('Priority')}

    + {/if} +
    +
    + +
    +
    +
    Scope
    + + {#if fieldError('ScopeType')} +

    {fieldError('ScopeType')}

    + {/if} +
    + +
    +
    Active
    + + {#if fieldError('Active')} +

    {fieldError('Active')}

    + {/if} +
    +
    + + {#if value.ScopeType === 'TESTSITE'} +
    +
    Test Site
    +
    +
    + + {#if value.TestSiteID && !selectedTestSite} +

    Current TestSiteID: {value.TestSiteID}

    + {/if} + {#if fieldError('TestSiteID')} +

    {fieldError('TestSiteID')}

    + {/if} +
    +
    + +
    +
    +
    + {/if} +
    + +
    +
    +
    +
    Condition Expression
    + ExpressionLanguage (ternary) +
    +
    + +
    + {#if fieldError('ConditionExpr')} +

    {fieldError('ConditionExpr')}

    + {/if} +
    +
    + + Examples +
    +
    order.isStat ? true : false
    +order.patient.age > 65 ? (order.priority > 5 ? true : false) : false
    +
    +
    +
    +
    diff --git a/src/lib/components/rules/TestSiteSearch.svelte b/src/lib/components/rules/TestSiteSearch.svelte new file mode 100644 index 0000000..f339912 --- /dev/null +++ b/src/lib/components/rules/TestSiteSearch.svelte @@ -0,0 +1,177 @@ + + + diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index eba87de..784ef02 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -6,6 +6,9 @@ // Test Types export * from './test.types.js'; +// Rules Types +export * from './rules.types.js'; + // Re-export specific types for convenience export type { TestType, @@ -24,3 +27,13 @@ export type { TestFormState, TabConfig } from './test.types.js'; + +export type { + RuleEventCode, + RuleScopeType, + RuleActionType, + RuleDef, + RuleAction, + RuleWithActions, + SetResultActionParams +} from './rules.types.js'; diff --git a/src/lib/types/rules.types.ts b/src/lib/types/rules.types.ts new file mode 100644 index 0000000..5387d99 --- /dev/null +++ b/src/lib/types/rules.types.ts @@ -0,0 +1,52 @@ +/** + * Rules Engine Type Definitions + * Based on CLQMS API Specification + */ + +export type RuleEventCode = 'ORDER_CREATED'; +export type RuleScopeType = 'GLOBAL' | 'TESTSITE'; +export type RuleActionType = 'SET_RESULT'; + +export interface RuleDef { + RuleID: number; + Name: string; + Description?: string; + EventCode: RuleEventCode | string; + ScopeType: RuleScopeType; + TestSiteID?: number | null; + ConditionExpr?: string | null; + Priority?: number; + Active: 0 | 1 | number; +} + +export interface RuleAction { + RuleActionID: number; + RuleID: number; + Seq: number; + ActionType: RuleActionType | string; + ActionParams: string | Record; +} + +export interface RuleWithActions { + rule: RuleDef; + actions: RuleAction[]; +} + +export interface ApiResponse { + status: 'success' | 'created' | 'error' | string; + message: string; + data: T; +} + +export interface RulesListResponse extends ApiResponse {} +export interface RuleDetailResponse extends ApiResponse {} +export interface RuleActionsListResponse extends ApiResponse {} + +export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {} + +export type SetResultActionParams = { + testSiteID?: number; + testSiteCode?: string; + value?: any; + valueExpr?: string; +}; diff --git a/src/routes/(app)/rules/+page.svelte b/src/routes/(app)/rules/+page.svelte new file mode 100644 index 0000000..c43409f --- /dev/null +++ b/src/routes/(app)/rules/+page.svelte @@ -0,0 +1,246 @@ + + +
    +
    + + + +
    +

    + + Rules +

    +

    Manage rules and actions for ORDER_CREATED

    +
    + + + New Rule + +
    + +
    +
    + + Filters +
    + +
    +
    +
    EventCode
    + +
    +
    +
    Active
    + +
    +
    +
    ScopeType
    + +
    +
    +
    TestSite
    + +
    +
    +
    Rule Name
    +
    + + +
    +
    +
    +
    + +
    + + {#snippet cell({ column, row, value })} + {#if column.key === 'ScopeType'} + {row.ScopeType || '-'} + {:else if column.key === 'Active'} +
    + + {isActive(row.Active) ? 'Yes' : 'No'} + +
    + {:else if column.key === 'Priority'} +
    {value ?? 0}
    + {:else if column.key === 'actions'} +
    + + +
    + {:else} + {value || '-'} + {/if} + {/snippet} +
    +
    +
    + + +
    +

    + Are you sure you want to delete {deleteItem?.Name}? +

    +

    RuleID: {deleteItem?.RuleID}

    +

    This action cannot be undone.

    +
    + {#snippet footer()} + + + {/snippet} +
    diff --git a/src/routes/(app)/rules/[ruleId]/+page.svelte b/src/routes/(app)/rules/[ruleId]/+page.svelte new file mode 100644 index 0000000..f34566c --- /dev/null +++ b/src/routes/(app)/rules/[ruleId]/+page.svelte @@ -0,0 +1,368 @@ + + +
    +
    + + + +
    +

    Edit Rule

    +

    RuleID: {ruleId ?? '-'}

    +
    + + +
    + +
    + + +
    + +
    +
    + +
    +
    +
    +

    Actions

    +

    Diff-based save (create/update/delete)

    +
    + +
    + +
    +
    + + + + +
    +

    + Are you sure you want to delete {ruleForm?.Name}? +

    +

    RuleID: {ruleId}

    +

    This action cannot be undone.

    +
    + {#snippet footer()} + + + {/snippet} +
    diff --git a/src/routes/(app)/rules/new/+page.svelte b/src/routes/(app)/rules/new/+page.svelte new file mode 100644 index 0000000..8f21bf3 --- /dev/null +++ b/src/routes/(app)/rules/new/+page.svelte @@ -0,0 +1,166 @@ + + +
    +
    + + + +
    +

    New Rule

    +

    Create a rule and its initial actions

    +
    + +
    + +
    + + +
    + +
    +
    + +
    + +
    +
    + +