mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 07:44:21 +00:00
fix(ui): align resources filters and drawer behavior
- move resources filters to the shared batch apply flow - render resource metadata as highlighted JSON code - keep drawer finding helper links interactive
This commit is contained in:
9 files changed
+405
-69
No files matched your search
@@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters
|
||||
- Resources now use batch-applied filters, render metadata JSON with syntax highlighting, simplify metadata/tags tabs, and keep finding-row Learn more links interactive inside the drawer
|
||||
|
||||
### ❌ Removed
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("notification indicator", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "notification-indicator.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses a popover for delta learn-more content so the link stays interactive", () => {
|
||||
expect(source).toContain("<Popover");
|
||||
expect(source).toContain("Learn more");
|
||||
expect(source).not.toContain("<Tooltip>");
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FINDING_DELTA, type FindingDelta } from "@/types";
|
||||
@@ -67,12 +62,17 @@ function DeltaIndicator({
|
||||
}: {
|
||||
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -82,9 +82,15 @@ function DeltaIndicator({
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="border-border-neutral-tertiary bg-bg-neutral-tertiary w-auto rounded-lg px-2 py-1.5 shadow-lg"
|
||||
sideOffset={4}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span>
|
||||
{delta === DeltaValues.NEW
|
||||
@@ -107,8 +113,8 @@ function DeltaIndicator({
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
|
||||
const mockSetPending = vi.fn();
|
||||
const mockApplyAll = vi.fn();
|
||||
const mockDiscardAll = vi.fn();
|
||||
const mockClearAndApply = vi.fn();
|
||||
const mockGetFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
vi.mock("@/hooks/use-filter-batch", () => ({
|
||||
useFilterBatch: () => ({
|
||||
pendingFilters: {
|
||||
"filter[region__in]": ["eu-west-1"],
|
||||
},
|
||||
setPending: mockSetPending,
|
||||
applyAll: mockApplyAll,
|
||||
discardAll: mockDiscardAll,
|
||||
clearAndApply: mockClearAndApply,
|
||||
hasChanges: true,
|
||||
changeCount: 1,
|
||||
getFilterValue: mockGetFilterValue,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
|
||||
ProviderTypeSelector: () => <div>Provider type selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
AccountsSelector: () => <div>Accounts selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/apply-filters-button", () => ({
|
||||
ApplyFiltersButton: ({
|
||||
hasChanges,
|
||||
changeCount,
|
||||
}: {
|
||||
hasChanges: boolean;
|
||||
changeCount: number;
|
||||
}) => (
|
||||
<div data-testid="apply-filters-button">
|
||||
{String(hasChanges)}:{changeCount}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: ({
|
||||
pendingCount,
|
||||
}: {
|
||||
pendingCount?: number;
|
||||
}) => <div data-testid="clear-filters-button">{pendingCount ?? 0}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/filter-summary-strip", () => ({
|
||||
FilterSummaryStrip: ({
|
||||
chips,
|
||||
}: {
|
||||
chips: Array<{ displayValue?: string; value: string }>;
|
||||
}) => (
|
||||
<div data-testid="filter-summary-strip">
|
||||
{chips.map((chip) => chip.displayValue ?? chip.value).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/expandable-section", () => ({
|
||||
ExpandableSection: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableFilterCustom: ({
|
||||
mode,
|
||||
}: {
|
||||
mode?: string;
|
||||
}) => <div data-testid="data-table-filter-custom">{mode}</div>,
|
||||
}));
|
||||
|
||||
import { ResourcesFilters } from "./resources-filters";
|
||||
|
||||
describe("ResourcesFilters", () => {
|
||||
it("uses batch mode controls and renders pending summary chips", () => {
|
||||
render(
|
||||
<ResourcesFilters
|
||||
providers={[]}
|
||||
uniqueRegions={["eu-west-1"]}
|
||||
uniqueServices={["ec2"]}
|
||||
uniqueResourceTypes={["aws_instance"]}
|
||||
uniqueGroups={["engineering_team"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("data-table-filter-custom")).toHaveTextContent(
|
||||
DATA_TABLE_FILTER_MODE.BATCH,
|
||||
);
|
||||
expect(screen.getByTestId("apply-filters-button")).toHaveTextContent(
|
||||
"true:1",
|
||||
);
|
||||
expect(screen.getByTestId("clear-filters-button")).toHaveTextContent("1");
|
||||
expect(screen.getByTestId("filter-summary-strip")).toHaveTextContent(
|
||||
"eu-west-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,25 @@ import { useState } from "react";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildResourcesFilterChips,
|
||||
getResourcesFilterDisplayValue,
|
||||
} from "./resources-filters.utils";
|
||||
|
||||
interface ResourcesFiltersProps {
|
||||
providers: ProviderProps[];
|
||||
uniqueRegions: string[];
|
||||
@@ -28,6 +40,16 @@ export const ResourcesFilters = ({
|
||||
uniqueGroups,
|
||||
}: ResourcesFiltersProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const {
|
||||
pendingFilters,
|
||||
setPending,
|
||||
applyAll,
|
||||
discardAll,
|
||||
clearAndApply,
|
||||
hasChanges,
|
||||
changeCount,
|
||||
getFilterValue,
|
||||
} = useFilterBatch();
|
||||
|
||||
// Custom filters for the expandable section
|
||||
const customFilters = [
|
||||
@@ -59,16 +81,35 @@ export const ResourcesFilters = ({
|
||||
];
|
||||
|
||||
const hasCustomFilters = customFilters.length > 0;
|
||||
const filterChips: FilterChip[] = buildResourcesFilterChips(
|
||||
pendingFilters,
|
||||
providers,
|
||||
);
|
||||
|
||||
const handleChipRemove = (filterKey: string, value: string) => {
|
||||
const currentValues = pendingFilters[filterKey] ?? [];
|
||||
const nextValues = currentValues.filter((item) => item !== value);
|
||||
setPending(filterKey, nextValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* First row: Provider selectors + More Filters button + Clear Filters */}
|
||||
{/* First row: Provider selectors + More Filters button + Apply/Clear */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<AccountsSelector providers={providers} />
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_id__in]")}
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
@@ -82,13 +123,42 @@ export const ResourcesFilters = ({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<ClearFiltersButton showCount />
|
||||
<ClearFiltersButton
|
||||
showCount
|
||||
onClear={clearAndApply}
|
||||
pendingCount={
|
||||
Object.values(pendingFilters).filter((values) => values.length > 0)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
<ApplyFiltersButton
|
||||
hasChanges={hasChanges}
|
||||
changeCount={changeCount}
|
||||
onApply={applyAll}
|
||||
onDiscard={discardAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterSummaryStrip chips={filterChips} onRemove={handleChipRemove} />
|
||||
|
||||
{/* Expandable filters section */}
|
||||
{hasCustomFilters && (
|
||||
<ExpandableSection isExpanded={isExpanded}>
|
||||
<DataTableFilterCustom filters={customFilters} hideClearButton />
|
||||
<DataTableFilterCustom
|
||||
filters={customFilters.map((filter) => ({
|
||||
...filter,
|
||||
labelFormatter: (value: string) =>
|
||||
getResourcesFilterDisplayValue(
|
||||
`filter[${filter.key}]`,
|
||||
value,
|
||||
providers,
|
||||
),
|
||||
}))}
|
||||
hideClearButton
|
||||
mode={DATA_TABLE_FILTER_MODE.BATCH}
|
||||
onBatchChange={setPending}
|
||||
getFilterValue={getFilterValue}
|
||||
/>
|
||||
</ExpandableSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getGroupLabel } from "@/lib/categories";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[region__in]": "Region",
|
||||
"filter[service__in]": "Service",
|
||||
"filter[type__in]": "Type",
|
||||
"filter[groups__in]": "Group",
|
||||
};
|
||||
|
||||
function getProviderAccountDisplayValue(
|
||||
providerId: string,
|
||||
providers: ProviderProps[],
|
||||
): string {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
if (!provider) {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
return provider.attributes.alias || provider.attributes.uid || providerId;
|
||||
}
|
||||
|
||||
export function getResourcesFilterDisplayValue(
|
||||
filterKey: string,
|
||||
value: string,
|
||||
providers: ProviderProps[],
|
||||
): string {
|
||||
if (!value) return value;
|
||||
|
||||
if (filterKey === "filter[provider_type__in]") {
|
||||
return getProviderDisplayName(value);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[provider_id__in]") {
|
||||
return getProviderAccountDisplayValue(value, providers);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[type__in]") {
|
||||
return formatLabel(value, "_");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildResourcesFilterChips(
|
||||
pendingFilters: Record<string, string[]>,
|
||||
providers: ProviderProps[],
|
||||
): FilterChip[] {
|
||||
const chips: FilterChip[] = [];
|
||||
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
|
||||
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
|
||||
|
||||
values.forEach((value) => {
|
||||
chips.push({
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
displayValue: getResourcesFilterDisplayValue(key, value, providers),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return chips;
|
||||
}
|
||||
@@ -26,4 +26,15 @@ describe("resource detail content", () => {
|
||||
expect(source).not.toContain("useEffect");
|
||||
expect(source).not.toContain("useEffect(");
|
||||
});
|
||||
|
||||
it("renders resource metadata with the shared code editor JSON highlighting instead of a plain pre block", () => {
|
||||
expect(source).toContain("QueryCodeEditor");
|
||||
expect(source).toContain("QUERY_EDITOR_LANGUAGE.JSON");
|
||||
expect(source).not.toContain("<pre");
|
||||
});
|
||||
|
||||
it("refreshes findings locally after mute without forcing a router refresh", () => {
|
||||
expect(source).toContain("setFindingsReloadNonce");
|
||||
expect(source).not.toContain("router.refresh()");
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import {
|
||||
Check,
|
||||
Container,
|
||||
Copy,
|
||||
CornerDownRight,
|
||||
ExternalLink,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
@@ -29,6 +26,10 @@ import {
|
||||
InfoTooltip,
|
||||
} from "@/components/shadcn/info-field/info-field";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
@@ -115,11 +116,9 @@ export const ResourceDetailContent = ({
|
||||
);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeTab, setActiveTab] = useState("findings");
|
||||
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const resource = resourceDetails;
|
||||
const resourceId = resource.id;
|
||||
@@ -155,12 +154,6 @@ export const ResourceDetailContent = ({
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
const copyMetadata = async (metadata: Record<string, unknown>) => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
|
||||
setMetadataCopied(true);
|
||||
setTimeout(() => setMetadataCopied(false), 2000);
|
||||
};
|
||||
|
||||
const navigateToFinding = async (findingId: string) => {
|
||||
setSelectedFindingId(findingId);
|
||||
await loadFindingDetails(findingId);
|
||||
@@ -177,7 +170,6 @@ export const ResourceDetailContent = ({
|
||||
|
||||
setRowSelection({});
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const failedFindings = findingsData;
|
||||
@@ -452,30 +444,16 @@ export const ResourceDetailContent = ({
|
||||
)}
|
||||
|
||||
{hasMetadata && parsedMetadata && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Metadata:
|
||||
</span>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary relative w-full rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMetadata(parsedMetadata)}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
|
||||
aria-label="Copy metadata to clipboard"
|
||||
>
|
||||
{metadataCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(parsedMetadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<QueryCodeEditor
|
||||
ariaLabel="Resource metadata"
|
||||
language={QUERY_EDITOR_LANGUAGE.JSON}
|
||||
value={JSON.stringify(parsedMetadata, null, 2)}
|
||||
copyValue={JSON.stringify(parsedMetadata, null, 2)}
|
||||
editable={false}
|
||||
minHeight={220}
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!attributes.details?.trim() && !hasMetadata && (
|
||||
@@ -487,20 +465,13 @@ export const ResourceDetailContent = ({
|
||||
|
||||
<TabsContent value="tags" className="flex flex-col gap-4">
|
||||
{hasTags ? (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Tags:
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{tagEntries.map(([key, value]) => (
|
||||
<InfoField key={key} label={key} variant="compact">
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{tagEntries.map(([key, value]) => (
|
||||
<InfoField key={key} label={key} variant="compact">
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No tags available for this resource.
|
||||
|
||||
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
export const QUERY_EDITOR_LANGUAGE = {
|
||||
OPEN_CYPHER: "openCypher",
|
||||
PLAIN_TEXT: "plainText",
|
||||
JSON: "json",
|
||||
SHELL: "shell",
|
||||
HCL: "hcl",
|
||||
BICEP: "bicep",
|
||||
@@ -205,6 +206,74 @@ const HCL_FUNCTIONS = new Set([
|
||||
"cidrhost",
|
||||
]);
|
||||
|
||||
interface JsonParserState {
|
||||
inString: boolean;
|
||||
stringIsProperty: boolean;
|
||||
escapeNext: boolean;
|
||||
}
|
||||
|
||||
const jsonLanguage = StreamLanguage.define<JsonParserState>({
|
||||
startState() {
|
||||
return {
|
||||
inString: false,
|
||||
stringIsProperty: false,
|
||||
escapeNext: false,
|
||||
};
|
||||
},
|
||||
token(stream, state) {
|
||||
if (state.inString) {
|
||||
while (!stream.eol()) {
|
||||
const next = stream.next();
|
||||
|
||||
if (state.escapeNext) {
|
||||
state.escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === "\\") {
|
||||
state.escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === '"') {
|
||||
state.inString = false;
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
}
|
||||
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream.peek() === '"') {
|
||||
const restOfLine = stream.string.slice(stream.pos);
|
||||
state.inString = true;
|
||||
state.escapeNext = false;
|
||||
state.stringIsProperty = /^\s*"([^"\\]|\\.)*"\s*:/.test(restOfLine);
|
||||
stream.next();
|
||||
return state.stringIsProperty ? "propertyName" : "string";
|
||||
}
|
||||
|
||||
if (stream.match(/[{}\[\],:]/)) {
|
||||
return "punctuation";
|
||||
}
|
||||
|
||||
if (stream.match(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/)) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (stream.match(/\b(?:true|false|null)\b/)) {
|
||||
return "keyword";
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const BICEP_KEYWORDS = new Set([
|
||||
"resource",
|
||||
"module",
|
||||
@@ -1171,6 +1240,8 @@ export const QueryCodeEditor = ({
|
||||
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
|
||||
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.JSON) {
|
||||
extensions.push(jsonLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
|
||||
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
|
||||
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
|
||||
|
||||
Reference in new issue
Block a user