-
Notifications
You must be signed in to change notification settings - Fork 59
Add rule authoring/editing UI backed by the rule-drafts API #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5333ad8
8a483fb
353a180
0ec81df
62beabb
a173253
4089033
2c65500
ba29dff
5292f96
20e851c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,13 @@ | ||||||||||||||||||||||||||||||
| import HTTPUtils, { HTTPResponse } from '../utils/HTTPUtils'; | ||||||||||||||||||||||||||||||
| import { RulesListResponse } from '../types/RulesTypes'; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| ParseIntoBuilderResponse, | ||||||||||||||||||||||||||||||
| RuleDraft, | ||||||||||||||||||||||||||||||
| RuleDraftSourceResponse, | ||||||||||||||||||||||||||||||
| RuleDraftsListResponse, | ||||||||||||||||||||||||||||||
| RuleDraftValidationResponse, | ||||||||||||||||||||||||||||||
| RuleDraftVocabulary, | ||||||||||||||||||||||||||||||
| RulesListResponse, | ||||||||||||||||||||||||||||||
| } from '../types/RulesTypes'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function getRulesList(): Promise<RulesListResponse> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.get('rules'); | ||||||||||||||||||||||||||||||
|
|
@@ -8,3 +16,83 @@ export async function getRulesList(): Promise<RulesListResponse> { | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw new Error(response.error.message ?? 'Failed to fetch rules list'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function getRuleDraftSource(path: string): Promise<RuleDraftSourceResponse> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.get('rule-drafts/source', { params: { path } }); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw new Error(response.error.message ?? `Failed to fetch rule source at ${path}`); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function validateRuleDraft(path: string, source: string): Promise<RuleDraftValidationResponse> { | ||||||||||||||||||||||||||||||
| // SML validation errors come back as 200 with {ok: false}; backend-shape problems come back as 400 | ||||||||||||||||||||||||||||||
| // with the same envelope. Both paths surface the structured errors to the UI without throwing. | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.post('rule-drafts/validate', { path, source }); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if (response.error.response?.data) { | ||||||||||||||||||||||||||||||
| return response.error.response.data as RuleDraftValidationResponse; | ||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Validate the error envelope before returning it. A 401, 403, 500, or proxy response may contain Proposed fix- if (response.error.response?.data) {
- return response.error.response.data as RuleDraftValidationResponse;
+ const payload = response.error.response?.data;
+ if (
+ typeof payload === 'object' &&
+ payload !== null &&
+ 'ok' in payload &&
+ typeof payload.ok === 'boolean' &&
+ 'errors' in payload &&
+ Array.isArray(payload.errors) &&
+ 'warnings' in payload &&
+ Array.isArray(payload.warnings)
+ ) {
+ return payload as RuleDraftValidationResponse;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw new Error(response.error.message ?? 'Validation request failed'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function parseRuleDraftIntoBuilder(path: string, source: string): Promise<ParseIntoBuilderResponse> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.post('rule-drafts/parse-into-builder', { path, source }); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw new Error(response.error.message ?? 'Failed to parse rule into builder model'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function getRuleDraftVocabulary(): Promise<RuleDraftVocabulary> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.get('rule-drafts/vocabulary'); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw new Error(response.error.message ?? 'Failed to fetch rule vocabulary'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export interface CreateRuleDraftBody { | ||||||||||||||||||||||||||||||
| path: string; | ||||||||||||||||||||||||||||||
| source: string; | ||||||||||||||||||||||||||||||
| rule_name: string; | ||||||||||||||||||||||||||||||
| summary: string; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Saves a draft into the rule_drafts table (upserted by path). The draft is staged | ||||||||||||||||||||||||||||||
| // for a developer to review and deploy; saving never changes any live rules. | ||||||||||||||||||||||||||||||
| export async function createRuleDraft(body: CreateRuleDraftBody): Promise<RuleDraft> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.post('rule-drafts', body); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const errPayload = response.error.response?.data as { error?: string } | undefined; | ||||||||||||||||||||||||||||||
| throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to save rule draft'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Loads a single draft (its SML lives in the table, not on disk, so editing a draft | ||||||||||||||||||||||||||||||
| // reads it from here rather than from the rules directory). | ||||||||||||||||||||||||||||||
| export async function getRuleDraft(id: number): Promise<RuleDraft> { | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.get(`rule-drafts/${id}`); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const errPayload = response.error.response?.data as { error?: string } | undefined; | ||||||||||||||||||||||||||||||
| throw new Error(errPayload?.error ?? response.error.message ?? 'Failed to load rule draft'); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function getRuleDrafts(): Promise<RuleDraftsListResponse> { | ||||||||||||||||||||||||||||||
| // Returns an empty list rather than throwing on failure so the RulesPage still renders | ||||||||||||||||||||||||||||||
| // when the caller lacks the rule-drafts ability. | ||||||||||||||||||||||||||||||
| const response: HTTPResponse = await HTTPUtils.get('rule-drafts'); | ||||||||||||||||||||||||||||||
| if (response.ok) { | ||||||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const errPayload = response.error.response?.data as RuleDraftsListResponse | undefined; | ||||||||||||||||||||||||||||||
| if (errPayload && Array.isArray(errPayload.drafts)) { | ||||||||||||||||||||||||||||||
| return errPayload; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return { drafts: [] }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| .viewContainer { | ||
| height: 100%; | ||
| width: 100%; | ||
| display: flex; | ||
| flex-direction: column; | ||
| overflow: hidden; | ||
| } | ||
|
|
||
| .scrollArea { | ||
| flex: 1; | ||
| overflow-y: auto; | ||
| padding: 24px; | ||
| } | ||
|
|
||
| .headerRow { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 16px; | ||
| margin-bottom: 16px; | ||
| flex-wrap: wrap; | ||
| } | ||
|
|
||
| .headerLeft { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 2px; | ||
| min-width: 0; | ||
| } | ||
|
|
||
| .headerActions { | ||
| display: flex; | ||
| gap: 8px; | ||
| align-items: center; | ||
| } | ||
|
|
||
| .editorGrid { | ||
| display: grid; | ||
| grid-template-columns: minmax(0, 1fr) 360px; | ||
| gap: 16px; | ||
| align-items: start; | ||
| } | ||
|
|
||
| @media (max-width: 1100px) { | ||
| .editorGrid { | ||
| grid-template-columns: 1fr; | ||
| } | ||
| } | ||
|
|
||
| .codeArea { | ||
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Remove the quotes around Stylelint reports - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;Also applies to: 67-67, 94-94, 131-131, 137-137 🧰 Tools🪛 Stylelint (17.14.0)[error] 51-51: Expected no quotes around "SFMono-Regular" (font-family-name-quotes) (font-family-name-quotes) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| font-size: 13px; | ||
| line-height: 1.5; | ||
| min-height: 480px; | ||
| } | ||
|
|
||
| .sidePanel { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 12px; | ||
| position: sticky; | ||
| top: 0; | ||
| } | ||
|
|
||
| .validationCard pre { | ||
| margin: 0; | ||
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| font-size: 12px; | ||
| white-space: pre-wrap; | ||
| overflow-wrap: break-word; | ||
| } | ||
|
|
||
| .errorList { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 8px; | ||
| } | ||
|
|
||
| .errorItem { | ||
| padding: 8px 10px; | ||
| background: var(--background-secondary); | ||
| border-left: 3px solid var(--status-error); | ||
| border-radius: 2px; | ||
| } | ||
|
|
||
| .warningItem { | ||
| padding: 8px 10px; | ||
| background: var(--background-secondary); | ||
| border-left: 3px solid var(--status-warning); | ||
| border-radius: 2px; | ||
| } | ||
|
|
||
| .errorLocation { | ||
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| font-size: 11px; | ||
| color: var(--text-light-secondary); | ||
| } | ||
|
|
||
| .builderSection { | ||
| margin-bottom: 16px; | ||
| } | ||
|
|
||
| .builderRow { | ||
| display: grid; | ||
| grid-template-columns: minmax(140px, 1fr) 130px minmax(140px, 1fr) 32px; | ||
| gap: 8px; | ||
| align-items: start; | ||
| margin-bottom: 8px; | ||
| } | ||
|
|
||
| .builderRowOutcome { | ||
| display: grid; | ||
| grid-template-columns: minmax(140px, 1fr) 32px; | ||
| gap: 8px; | ||
| align-items: start; | ||
| margin-bottom: 8px; | ||
| } | ||
|
|
||
| .builderArgsGrid { | ||
| display: grid; | ||
| grid-template-columns: 120px 1fr; | ||
| gap: 6px; | ||
| margin-top: 4px; | ||
| margin-left: 0; | ||
| padding: 8px; | ||
| background: var(--background-secondary); | ||
| border-radius: 4px; | ||
| } | ||
|
|
||
| .builderArgLabel { | ||
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| font-size: 12px; | ||
| align-self: center; | ||
| } | ||
|
|
||
| .previewBlock { | ||
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| font-size: 12px; | ||
| padding: 12px; | ||
| background: var(--background-secondary); | ||
| border: 1px solid var(--divider); | ||
| border-radius: 4px; | ||
| white-space: pre-wrap; | ||
| overflow-wrap: break-word; | ||
| } | ||
|
|
||
| .footnote { | ||
| font-size: 11px; | ||
| color: var(--text-light-secondary); | ||
| margin-top: 4px; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seeing as this is python, it should be
wire_into_main=True.