mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Compare commits
4 Commits
fix/github
...
v3.17.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b7a69cea | ||
|
|
a9015b008d | ||
|
|
d19d624c0c | ||
|
|
3edaab6c2b |
@@ -37,7 +37,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
helmfile-deploy:
|
helmfile-deploy:
|
||||||
|
|||||||
17
.github/workflows/formbricks-release.yml
vendored
17
.github/workflows/formbricks-release.yml
vendored
@@ -7,12 +7,13 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
|
||||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Build & release docker image
|
name: Build & release docker image
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
uses: ./.github/workflows/release-docker-github.yml
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
@@ -20,6 +21,9 @@ jobs:
|
|||||||
|
|
||||||
helm-chart-release:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
uses: ./.github/workflows/release-helm-chart.yml
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
needs:
|
needs:
|
||||||
@@ -29,6 +33,9 @@ jobs:
|
|||||||
|
|
||||||
deploy-formbricks-cloud:
|
deploy-formbricks-cloud:
|
||||||
name: Deploy Helm Chart to Formbricks Cloud
|
name: Deploy Helm Chart to Formbricks Cloud
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||||
needs:
|
needs:
|
||||||
@@ -36,7 +43,7 @@ jobs:
|
|||||||
- helm-chart-release
|
- helm-chart-release
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||||
|
|
||||||
upload-sentry-sourcemaps:
|
upload-sentry-sourcemaps:
|
||||||
name: Upload Sentry Sourcemaps
|
name: Upload Sentry Sourcemaps
|
||||||
@@ -64,4 +71,4 @@ jobs:
|
|||||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
environment: ${{ env.ENVIRONMENT }}
|
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||||
|
|||||||
@@ -89,4 +89,94 @@ describe("QuestionFilterComboBox", () => {
|
|||||||
await userEvent.click(comboBoxOpenerButton);
|
await userEvent.click(comboBoxOpenerButton);
|
||||||
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows text input for URL meta field", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: "Contains",
|
||||||
|
filterComboBoxValue: "example.com",
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
const textInput = screen.getByDisplayValue("example.com");
|
||||||
|
expect(textInput).toBeInTheDocument();
|
||||||
|
expect(textInput).toHaveAttribute("type", "text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text input is disabled when no filter value is selected for URL field", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: undefined,
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
const textInput = screen.getByRole("textbox");
|
||||||
|
expect(textInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: "Contains",
|
||||||
|
filterComboBoxValue: "",
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
const textInput = screen.getByRole("textbox");
|
||||||
|
await userEvent.type(textInput, "t");
|
||||||
|
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows regular combobox for non-URL meta fields", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "source",
|
||||||
|
filterValue: "Equals",
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows regular combobox for URL field with non-text operations", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Other",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: "Equals",
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text input handles string filter combo box values correctly", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: "Contains",
|
||||||
|
filterComboBoxValue: "test-url",
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
const textInput = screen.getByDisplayValue("test-url");
|
||||||
|
expect(textInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text input handles non-string filter combo box values gracefully", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
type: "Meta",
|
||||||
|
fieldId: "url",
|
||||||
|
filterValue: "Contains",
|
||||||
|
filterComboBoxValue: ["array-value"],
|
||||||
|
} as any;
|
||||||
|
render(<QuestionFilterComboBox {...props} />);
|
||||||
|
const textInput = screen.getByRole("textbox");
|
||||||
|
expect(textInput).toHaveValue("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type QuestionFilterComboBoxProps = {
|
|||||||
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||||
handleRemoveMultiSelect: (value: string[]) => void;
|
handleRemoveMultiSelect: (value: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
fieldId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuestionFilterComboBox = ({
|
export const QuestionFilterComboBox = ({
|
||||||
@@ -45,6 +46,7 @@ export const QuestionFilterComboBox = ({
|
|||||||
type,
|
type,
|
||||||
handleRemoveMultiSelect,
|
handleRemoveMultiSelect,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
fieldId,
|
||||||
}: QuestionFilterComboBoxProps) => {
|
}: QuestionFilterComboBoxProps) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||||
@@ -75,6 +77,9 @@ export const QuestionFilterComboBox = ({
|
|||||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||||
|
|
||||||
|
// Check if this is a URL field with string comparison operations that require text input
|
||||||
|
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||||
|
|
||||||
const filteredOptions = options?.filter((o) =>
|
const filteredOptions = options?.filter((o) =>
|
||||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -161,70 +166,80 @@ export const QuestionFilterComboBox = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
{isTextInputField ? (
|
||||||
<div
|
<Input
|
||||||
className={clsx(
|
type="text"
|
||||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||||
)}>
|
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
disabled={disabled || !filterValue}
|
||||||
filterComboBoxItem
|
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||||
) : (
|
/>
|
||||||
|
) : (
|
||||||
|
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||||
|
)}>
|
||||||
|
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||||
|
filterComboBoxItem
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||||
|
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 text-left text-slate-400",
|
||||||
|
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||||
|
)}>
|
||||||
|
{t("common.select")}...
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex-1 text-left text-slate-400",
|
"ml-2 flex items-center justify-center",
|
||||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||||
)}>
|
)}>
|
||||||
{t("common.select")}...
|
{open ? (
|
||||||
|
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
<div className="relative mt-2 h-full">
|
||||||
type="button"
|
{open && (
|
||||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
<CommandList>
|
||||||
className={clsx(
|
<div className="p-2">
|
||||||
"ml-2 flex items-center justify-center",
|
<Input
|
||||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
type="text"
|
||||||
)}>
|
autoFocus
|
||||||
{open ? (
|
placeholder={t("common.search") + "..."}
|
||||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
value={searchQuery}
|
||||||
) : (
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{filteredOptions?.map((o, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||||
|
onSelect={() => commandItemOnSelect(o)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</Command>
|
||||||
<div className="relative mt-2 h-full">
|
)}
|
||||||
{open && (
|
|
||||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
|
||||||
<CommandList>
|
|
||||||
<div className="p-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
placeholder={t("common.search") + "..."}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{filteredOptions?.map((o, index) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
|
||||||
onSelect={() => commandItemOnSelect(o)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Command>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
HomeIcon,
|
HomeIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LanguagesIcon,
|
LanguagesIcon,
|
||||||
|
LinkIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
@@ -94,6 +95,7 @@ const questionIcons = {
|
|||||||
source: ArrowUpFromDotIcon,
|
source: ArrowUpFromDotIcon,
|
||||||
action: MousePointerClickIcon,
|
action: MousePointerClickIcon,
|
||||||
country: FlagIcon,
|
country: FlagIcon,
|
||||||
|
url: LinkIcon,
|
||||||
|
|
||||||
// others
|
// others
|
||||||
Language: LanguagesIcon,
|
Language: LanguagesIcon,
|
||||||
@@ -138,7 +140,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
|||||||
|
|
||||||
const getLabelStyle = (): string | undefined => {
|
const getLabelStyle = (): string | undefined => {
|
||||||
if (type !== OptionsType.META) return undefined;
|
if (type !== OptionsType.META) return undefined;
|
||||||
return label === "os" ? "uppercase" : "capitalize";
|
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -246,9 +246,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||||
<div
|
<div
|
||||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||||
key={`${s.questionType.id}-${i}`}>
|
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||||
<QuestionsComboBox
|
<QuestionsComboBox
|
||||||
key={`${s.questionType.label}-${i}`}
|
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||||
options={questionComboBoxOptions}
|
options={questionComboBoxOptions}
|
||||||
selected={s.questionType}
|
selected={s.questionType}
|
||||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||||
@@ -276,6 +276,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
? s?.questionType?.questionType
|
? s?.questionType?.questionType
|
||||||
: s?.questionType?.type
|
: s?.questionType?.type
|
||||||
}
|
}
|
||||||
|
fieldId={s?.questionType?.id}
|
||||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||||
|
|||||||
@@ -231,6 +231,43 @@ describe("surveys", () => {
|
|||||||
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||||
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should provide extended filter options for URL meta field", () => {
|
||||||
|
const survey = {
|
||||||
|
id: "survey1",
|
||||||
|
name: "Test Survey",
|
||||||
|
questions: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
environmentId: "env1",
|
||||||
|
status: "draft",
|
||||||
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
url: ["https://example.com", "https://test.com"],
|
||||||
|
source: ["web", "mobile"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
|
||||||
|
|
||||||
|
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
|
||||||
|
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
|
||||||
|
|
||||||
|
expect(urlFilterOption).toBeDefined();
|
||||||
|
expect(urlFilterOption?.filterOptions).toEqual([
|
||||||
|
"Equals",
|
||||||
|
"Not equals",
|
||||||
|
"Contains",
|
||||||
|
"Does not contain",
|
||||||
|
"Starts with",
|
||||||
|
"Does not start with",
|
||||||
|
"Ends with",
|
||||||
|
"Does not end with",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(sourceFilterOption).toBeDefined();
|
||||||
|
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getFormattedFilters", () => {
|
describe("getFormattedFilters", () => {
|
||||||
@@ -717,6 +754,119 @@ describe("surveys", () => {
|
|||||||
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
|
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
|
||||||
expect(result.tags?.applied).toContain("Tag 1");
|
expect(result.tags?.applied).toContain("Tag 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should format URL meta filters with string operations", () => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
|
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
|
||||||
|
expect(result.meta?.url).toEqual({ op: "contains", value: "example.com" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should format URL meta filters with all supported string operations", () => {
|
||||||
|
const testCases = [
|
||||||
|
{ filterValue: "Equals", expected: { op: "equals", value: "https://example.com" } },
|
||||||
|
{ filterValue: "Not equals", expected: { op: "notEquals", value: "https://example.com" } },
|
||||||
|
{ filterValue: "Contains", expected: { op: "contains", value: "example.com" } },
|
||||||
|
{ filterValue: "Does not contain", expected: { op: "doesNotContain", value: "test.com" } },
|
||||||
|
{ filterValue: "Starts with", expected: { op: "startsWith", value: "https://" } },
|
||||||
|
{ filterValue: "Does not start with", expected: { op: "doesNotStartWith", value: "http://" } },
|
||||||
|
{ filterValue: "Ends with", expected: { op: "endsWith", value: ".com" } },
|
||||||
|
{ filterValue: "Does not end with", expected: { op: "doesNotEndWith", value: ".org" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ filterValue, expected }) => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
|
filterType: { filterValue, filterComboBoxValue: expected.value },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
expect(result.meta?.url).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle URL meta filters with empty string values", () => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
|
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
|
||||||
|
expect(result.meta?.url).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle URL meta filters with whitespace-only values", () => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
|
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
|
||||||
|
expect(result.meta?.url).toEqual({ op: "contains", value: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should still handle existing meta filters with array values", () => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "source", id: "source" },
|
||||||
|
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
|
||||||
|
expect(result.meta?.source).toEqual({ op: "equals", value: "google" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mixed URL and traditional meta filters", () => {
|
||||||
|
const selectedFilter = {
|
||||||
|
responseStatus: "all",
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
|
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionType: { type: "Meta", label: "source", id: "source" },
|
||||||
|
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
|
||||||
|
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
||||||
|
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTodayDate", () => {
|
describe("getTodayDate", () => {
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ const filterOptions = {
|
|||||||
ranking: ["Filled out", "Skipped"],
|
ranking: ["Filled out", "Skipped"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// URL/meta text operators mapping
|
||||||
|
const META_OP_MAP = {
|
||||||
|
Equals: "equals",
|
||||||
|
"Not equals": "notEquals",
|
||||||
|
Contains: "contains",
|
||||||
|
"Does not contain": "doesNotContain",
|
||||||
|
"Starts with": "startsWith",
|
||||||
|
"Does not start with": "doesNotStartWith",
|
||||||
|
"Ends with": "endsWith",
|
||||||
|
"Does not end with": "doesNotEndWith",
|
||||||
|
} as const;
|
||||||
|
|
||||||
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
|
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
|
||||||
export const generateQuestionAndFilterOptions = (
|
export const generateQuestionAndFilterOptions = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
@@ -165,7 +177,7 @@ export const generateQuestionAndFilterOptions = (
|
|||||||
Object.keys(meta).forEach((m) => {
|
Object.keys(meta).forEach((m) => {
|
||||||
questionFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Meta",
|
type: "Meta",
|
||||||
filterOptions: ["Equals", "Not equals"],
|
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
|
||||||
filterComboBoxOptions: meta[m],
|
filterComboBoxOptions: meta[m],
|
||||||
id: m,
|
id: m,
|
||||||
});
|
});
|
||||||
@@ -481,17 +493,23 @@ export const getFormattedFilters = (
|
|||||||
if (meta.length) {
|
if (meta.length) {
|
||||||
meta.forEach(({ filterType, questionType }) => {
|
meta.forEach(({ filterType, questionType }) => {
|
||||||
if (!filters.meta) filters.meta = {};
|
if (!filters.meta) filters.meta = {};
|
||||||
if (!filterType.filterComboBoxValue) return;
|
|
||||||
if (filterType.filterValue === "Equals") {
|
// For text input cases (URL filtering)
|
||||||
filters.meta[questionType.label ?? ""] = {
|
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
||||||
op: "equals",
|
const value = filterType.filterComboBoxValue.trim();
|
||||||
value: filterType.filterComboBoxValue as string,
|
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
||||||
};
|
if (op) {
|
||||||
} else if (filterType.filterValue === "Not equals") {
|
filters.meta[questionType.label ?? ""] = { op, value };
|
||||||
filters.meta[questionType.label ?? ""] = {
|
}
|
||||||
op: "notEquals",
|
}
|
||||||
value: filterType.filterComboBoxValue as string,
|
// For dropdown/select cases (existing metadata fields)
|
||||||
};
|
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
||||||
|
const value = filterType.filterComboBoxValue[0]; // Take first selected value
|
||||||
|
if (filterType.filterValue === "Equals") {
|
||||||
|
filters.meta[questionType.label ?? ""] = { op: "equals", value };
|
||||||
|
} else if (filterType.filterValue === "Not equals") {
|
||||||
|
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,46 @@ describe("Response Utils", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("meta: URL string comparison operations", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: "contains",
|
||||||
|
criteria: { meta: { url: { op: "contains" as const, value: "example.com" } } },
|
||||||
|
expected: { meta: { path: ["url"], string_contains: "example.com" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doesNotContain",
|
||||||
|
criteria: { meta: { url: { op: "doesNotContain" as const, value: "test.com" } } },
|
||||||
|
expected: { NOT: { meta: { path: ["url"], string_contains: "test.com" } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "startsWith",
|
||||||
|
criteria: { meta: { url: { op: "startsWith" as const, value: "https://" } } },
|
||||||
|
expected: { meta: { path: ["url"], string_starts_with: "https://" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doesNotStartWith",
|
||||||
|
criteria: { meta: { url: { op: "doesNotStartWith" as const, value: "http://" } } },
|
||||||
|
expected: { NOT: { meta: { path: ["url"], string_starts_with: "http://" } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "endsWith",
|
||||||
|
criteria: { meta: { url: { op: "endsWith" as const, value: ".com" } } },
|
||||||
|
expected: { meta: { path: ["url"], string_ends_with: ".com" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doesNotEndWith",
|
||||||
|
criteria: { meta: { url: { op: "doesNotEndWith" as const, value: ".org" } } },
|
||||||
|
expected: { NOT: { meta: { path: ["url"], string_ends_with: ".org" } } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ criteria, expected }) => {
|
||||||
|
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
|
||||||
|
expect(result.AND).toEqual([{ AND: [expected] }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWhereClause – data‐field filter operations", () => {
|
describe("buildWhereClause – data‐field filter operations", () => {
|
||||||
@@ -495,10 +535,98 @@ describe("Response Utils", () => {
|
|||||||
expect(result.os).toContain("MacOS");
|
expect(result.os).toContain("MacOS");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should extract URL data correctly", () => {
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
url: "https://example.com/page1",
|
||||||
|
source: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
url: "https://test.com/page2?param=value",
|
||||||
|
source: "google",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||||
|
expect(result.url).toEqual([]);
|
||||||
|
expect(result.source).toContain("direct");
|
||||||
|
expect(result.source).toContain("google");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mixed meta data with URLs", () => {
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
userAgent: { browser: "Chrome", device: "desktop" },
|
||||||
|
url: "https://formbricks.com/dashboard",
|
||||||
|
country: "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
userAgent: { browser: "Safari", device: "mobile" },
|
||||||
|
url: "https://formbricks.com/surveys/123",
|
||||||
|
country: "UK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||||
|
expect(result.browser).toContain("Chrome");
|
||||||
|
expect(result.browser).toContain("Safari");
|
||||||
|
expect(result.device).toContain("desktop");
|
||||||
|
expect(result.device).toContain("mobile");
|
||||||
|
expect(result.url).toEqual([]);
|
||||||
|
expect(result.country).toContain("US");
|
||||||
|
expect(result.country).toContain("UK");
|
||||||
|
});
|
||||||
|
|
||||||
test("should handle empty responses", () => {
|
test("should handle empty responses", () => {
|
||||||
const result = getResponseMeta([]);
|
const result = getResponseMeta([]);
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should ignore empty or null URL values", () => {
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
url: "",
|
||||||
|
source: "direct",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
url: null as any,
|
||||||
|
source: "newsletter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
contactAttributes: {},
|
||||||
|
data: {},
|
||||||
|
meta: {
|
||||||
|
url: "https://valid.com",
|
||||||
|
source: "google",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
|
||||||
|
expect(result.url).toEqual([]);
|
||||||
|
expect(result.source).toEqual(expect.arrayContaining(["direct", "newsletter", "google"]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getResponseHiddenFields", () => {
|
describe("getResponseHiddenFields", () => {
|
||||||
|
|||||||
@@ -234,6 +234,60 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "contains":
|
||||||
|
meta.push({
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_contains: val.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "doesNotContain":
|
||||||
|
meta.push({
|
||||||
|
NOT: {
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_contains: val.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "startsWith":
|
||||||
|
meta.push({
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_starts_with: val.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "doesNotStartWith":
|
||||||
|
meta.push({
|
||||||
|
NOT: {
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_starts_with: val.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "endsWith":
|
||||||
|
meta.push({
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_ends_with: val.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "doesNotEndWith":
|
||||||
|
meta.push({
|
||||||
|
NOT: {
|
||||||
|
meta: {
|
||||||
|
path: updatedKey,
|
||||||
|
string_ends_with: val.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -726,10 +780,13 @@ export const getResponseMeta = (
|
|||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
Object.entries(response.meta).forEach(([key, value]) => {
|
Object.entries(response.meta).forEach(([key, value]) => {
|
||||||
// skip url
|
|
||||||
if (key === "url") return;
|
|
||||||
|
|
||||||
// Handling nested objects (like userAgent)
|
// Handling nested objects (like userAgent)
|
||||||
|
if (key === "url") {
|
||||||
|
if (!meta[key]) {
|
||||||
|
meta[key] = new Set();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
|
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
|
||||||
if (typeof nestedValue === "string" && nestedValue) {
|
if (typeof nestedValue === "string" && nestedValue) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
|||||||
|
|
||||||
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
|
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
|
||||||
|
|
||||||
|
> **Note:** [https://saml.formbricks.com](https://saml.formbricks.com) is hardcoded in Formbricks — do not replace it with your instance URL. It is the fixed SP Entity ID and must match exactly as shown in SAML assertions.
|
||||||
|
|
||||||
**Response:** Signed
|
**Response:** Signed
|
||||||
|
|
||||||
**Assertion Signature:** Signed
|
**Assertion Signature:** Signed
|
||||||
@@ -77,7 +79,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
|||||||
</Step>
|
</Step>
|
||||||
<Step title="Enter the SAML Integration Settings as shown and click Next">
|
<Step title="Enter the SAML Integration Settings as shown and click Next">
|
||||||
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
|
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
|
||||||
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com`
|
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com` (hardcoded; do not replace with your instance URL)
|
||||||
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
|
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
|
||||||
</Step>
|
</Step>
|
||||||
<Step title="Fill the fields mapping as shown and click Next">
|
<Step title="Fill the fields mapping as shown and click Next">
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export const ZResponseFilterCondition = z.enum([
|
|||||||
"isEmpty",
|
"isEmpty",
|
||||||
"isNotEmpty",
|
"isNotEmpty",
|
||||||
"isAnyOf",
|
"isAnyOf",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
"startsWith",
|
||||||
|
"doesNotStartWith",
|
||||||
|
"endsWith",
|
||||||
|
"doesNotEndWith",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type TResponseDataValue = z.infer<typeof ZResponseDataValue>;
|
export type TResponseDataValue = z.infer<typeof ZResponseDataValue>;
|
||||||
@@ -149,6 +155,36 @@ const ZResponseFilterCriteriaIsAnyOf = z.object({
|
|||||||
value: z.record(z.string(), z.array(z.string())),
|
value: z.record(z.string(), z.array(z.string())),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaContains = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.contains),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaDoesNotContain = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.doesNotContain),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaStartsWith = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.startsWith),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaDoesNotStartWith = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.doesNotStartWith),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaEndsWith = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.endsWith),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZResponseFilterCriteriaDoesNotEndWith = z.object({
|
||||||
|
op: z.literal(ZResponseFilterCondition.Values.doesNotEndWith),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const ZResponseFilterCriteriaFilledOut = z.object({
|
const ZResponseFilterCriteriaFilledOut = z.object({
|
||||||
op: z.literal("filledOut"),
|
op: z.literal("filledOut"),
|
||||||
});
|
});
|
||||||
@@ -217,10 +253,16 @@ export const ZResponseFilterCriteria = z.object({
|
|||||||
|
|
||||||
meta: z
|
meta: z
|
||||||
.record(
|
.record(
|
||||||
z.object({
|
z.union([
|
||||||
op: z.enum(["equals", "notEquals"]),
|
ZResponseFilterCriteriaDataEquals,
|
||||||
value: z.union([z.string(), z.number()]),
|
ZResponseFilterCriteriaDataNotEquals,
|
||||||
})
|
ZResponseFilterCriteriaContains,
|
||||||
|
ZResponseFilterCriteriaDoesNotContain,
|
||||||
|
ZResponseFilterCriteriaStartsWith,
|
||||||
|
ZResponseFilterCriteriaDoesNotStartWith,
|
||||||
|
ZResponseFilterCriteriaEndsWith,
|
||||||
|
ZResponseFilterCriteriaDoesNotEndWith,
|
||||||
|
])
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user