Compare commits

...

1 Commits

Author SHA1 Message Date
Thomas Schmidt
e16ae349b4 Suggest custom expression based on name 2024-07-16 07:30:05 +00:00
5 changed files with 727 additions and 80 deletions

View File

@@ -0,0 +1,429 @@
export const CUSTOM_EXPRESSIONS_LIST = [
{
"name": "Average",
"type": "Aggregate",
"syntax": "Average(column)",
"example": "Average([Quantity])",
"description": "Returns the average of the values in the column."
},
{
"name": "Count",
"type": "Aggregate",
"syntax": "Count",
"example": "Count",
"description": "Returns the count of rows in the selected data. Cannot be used with Arguments. To count values of a column use `Distinct(<column>)`."
},
{
"name": "CountIf",
"type": "Aggregate",
"syntax": "CountIf(condition)",
"example": "CountIf([Subtotal] > 100)",
"description": "Only counts rows where the condition is true."
},
{
"name": "CumulativeCount",
"type": "Aggregate",
"syntax": "CumulativeCount",
"example": "CumulativeCount",
"description": "The additive total of rows across a breakout."
},
{
"name": "CumulativeSum",
"type": "Aggregate",
"syntax": "CumulativeSum(column)",
"example": "CumulativeSum([Subtotal])",
"description": "The rolling sum of a column across a breakout."
},
{
"name": "Distinct",
"type": "Aggregate",
"syntax": "Distinct(column)",
"example": "Distinct([Last Name])",
"description": "The number of distinct values in this column. Performs a count of unique values."
},
{
"name": "Max",
"type": "Aggregate",
"syntax": "Max(column)",
"example": "Max([Age])",
"description": "Returns the largest value found in the column."
},
{
"name": "Median",
"type": "Aggregate",
"syntax": "Median(column)",
"example": "Median([Age])",
"description": "Returns the median value of the specified column."
},
{
"name": "Min",
"type": "Aggregate",
"syntax": "Min(column)",
"example": "Min([Salary])",
"description": "Returns the smallest value found in the column."
},
{
"name": "Percentile",
"type": "Aggregate",
"syntax": "Percentile(column, percentile-value)",
"example": "Percentile([Score], 0.9)",
"description": "Returns the value of the column at the percentile value."
},
{
"name": "Share",
"type": "Aggregate",
"syntax": "Share(condition)",
"example": "Share([Color] = \"Blue\")",
"description": "Returns the percent of rows in the data that match the condition, as a decimal."
},
{
"name": "StandardDeviation",
"type": "Aggregate",
"syntax": "StandardDeviation(column)",
"example": "StandardDeviation([Population])",
"description": "Calculates the standard deviation of the column, which is a measure of the variation in a set of values."
},
{
"name": "Sum",
"type": "Aggregate",
"syntax": "Sum(column)",
"example": "Sum([Subtotal])",
"description": "Adds up all the values of the column."
},
{
"name": "SumIf",
"type": "Aggregate",
"syntax": "SumIf(column, condition)",
"example": "SumIf([Subtotal], [Order Status] = \"Valid\")",
"description": "Sums up the specified column only for rows where the condition is true."
},
{
"name": "Variance",
"type": "Aggregate",
"syntax": "Variance(column)",
"example": "Variance([Temperature])",
"description": "Returns the numeric variance for a given column."
},
{
"name": "between",
"type": "Function",
"syntax": "between(column, start, end)",
"example": "between([Created At], \"2019-01-01\", \"2020-12-31\")",
"description": "Checks a date or number column’s values to see if they’re within the specified range."
},
{
"name": "case",
"type": "Function",
"syntax": "case(condition, output, …)",
"example": "case([Weight] > 200, \"Large\", [Weight] > 150, \"Medium\", \"Small\")",
"description": "Tests an expression against a list of cases and returns the corresponding value of the first matching case, with an optional default value if nothing else is met."
},
{
"name": "coalesce",
"type": "Function",
"syntax": "coalesce(value1, value2, …)",
"example": "coalesce([Comments], [Notes], \"No comments\")",
"description": "Looks at the values in each argument in order and returns the first non-null value for each row."
},
{
"name": "isnull",
"type": "Function",
"syntax": "isnull(column)",
"example": "isnull([Tax])",
"description": "Returns true if the column is null."
},
{
"name": "notnull",
"type": "Function",
"syntax": "notnull(column)",
"example": "notnull([Tax])",
"description": "Returns true if the column contains a value."
},
{
"name": "abs",
"type": "Function",
"syntax": "abs(column)",
"example": "abs([Debt])",
"description": "Returns the absolute (positive) value of the specified column."
},
{
"name": "ceil",
"type": "Function",
"syntax": "ceil(column)",
"example": "ceil([Price])",
"description": "Rounds a decimal up (ceil as in ceiling)."
},
{
"name": "exp",
"type": "Function",
"syntax": "exp(column)",
"example": "exp([Interest Months])",
"description": "Returns Euler’s number, e, raised to the power of the supplied number."
},
{
"name": "floor",
"type": "Function",
"syntax": "floor(column)",
"example": "floor([Price])",
"description": "Rounds a decimal number down."
},
{
"name": "log",
"type": "Function",
"syntax": "log(column)",
"example": "log([Value])",
"description": "Returns the base 10 log of the number."
},
{
"name": "power",
"type": "Function",
"syntax": "power(column, exponent)",
"example": "power([Length], 2)",
"description": "Raises a number to the power of the exponent value."
},
{
"name": "round",
"type": "Function",
"syntax": "round(column)",
"example": "round([Temperature])",
"description": "Rounds a decimal number either up or down to the nearest integer value."
},
{
"name": "sqrt",
"type": "Function",
"syntax": "sqrt(column)",
"example": "sqrt([Hypotenuse])",
"description": "Returns the square root of a value."
},
{
"name": "concat",
"type": "Function",
"syntax": "concat(value1, value2, …)",
"example": "concat([Last Name], \", \", [First Name])",
"description": "Combine two or more strings together."
},
{
"name": "contains",
"type": "Function",
"syntax": "contains(string1, string2)",
"example": "contains([Status], \"Class\")",
"description": "Checks to see if string1 contains string2 within it."
},
{
"name": "doesNotContain",
"type": "Function",
"syntax": "doesNotContain(string1, string2)",
"example": "doesNotContain([Status], \"Class\")",
"description": "Checks to see if string1 does not contain string2 within it."
},
{
"name": "endsWith",
"type": "Function",
"syntax": "endsWith(text, comparison)",
"example": "endsWith([Appetite], \"hungry\")",
"description": "Returns true if the end of the text matches the comparison text."
},
{
"name": "isempty",
"type": "Function",
"syntax": "isempty(column)",
"example": "isempty([Feedback])",
"description": "Returns true if a string column contains an empty string or is null."
},
{
"name": "ltrim",
"type": "Function",
"syntax": "ltrim(text)",
"example": "ltrim([Comment])",
"description": "Removes leading whitespace from a string of text."
},
{
"name": "length",
"type": "Function",
"syntax": "length(text)",
"example": "length([Comment])",
"description": "Returns the number of characters in text."
},
{
"name": "lower",
"type": "Function",
"syntax": "lower(text)",
"example": "lower([Status])",
"description": "Returns the string of text in all lower case."
},
{
"name": "notempty",
"type": "Function",
"syntax": "notempty(column)",
"example": "notempty([Feedback])",
"description": "Returns true if a string column contains a value that is not the empty string."
},
{
"name": "regexextract",
"type": "Function",
"syntax": "regexextract(text, regular_expression)",
"example": "regexextract([Address], \"[0-9]+\")",
"description": "Extracts matching substrings according to a regular expression."
},
{
"name": "replace",
"type": "Function",
"syntax": "replace(text, find, replace)",
"example": "replace([Title], \"Enormous\", \"Gigantic\")",
"description": "Replaces all occurrences of a search text in the input text with the replacement text."
},
{
"name": "rtrim",
"type": "Function",
"syntax": "rtrim(text)",
"example": "rtrim([Comment])",
"description": "Removes trailing whitespace from a string of text."
},
{
"name": "startsWith",
"type": "Function",
"syntax": "startsWith(text, comparison)",
"example": "startsWith([Course Name], \"Computer Science\")",
"description": "Returns true if the beginning of the text matches the comparison text."
},
{
"name": "substring",
"type": "Function",
"syntax": "substring(text, position, length)",
"example": "substring([Title], 1, 10)",
"description": "Returns a portion of the supplied text, specified by a starting position and a length."
},
{
"name": "trim",
"type": "Function",
"syntax": "trim(text)",
"example": "trim([Comment])",
"description": "Removes leading and trailing whitespace from a string of text."
},
{
"name": "upper",
"type": "Function",
"syntax": "upper(text)",
"example": "upper([Status])",
"description": "Returns the text in all upper case."
},
{
"name": "convertTimezone",
"type": "Function",
"syntax": "convertTimezone(column, target, source)",
"example": "convertTimezone(\"2022-12-28T12:00:00\", \"Canada/Pacific\", \"Canada/Eastern\")",
"description": "Shifts a date or timestamp value into a specified time zone."
},
{
"name": "datetimeAdd",
"type": "Function",
"syntax": "datetimeAdd(column, amount, unit)",
"example": "datetimeAdd(\"2021-03-25\", 1, \"month\")",
"description": "Adds some unit of time to a date or timestamp value."
},
{
"name": "datetimeDiff",
"type": "Function",
"syntax": "datetimeDiff(datetime1, datetime2, unit)",
"example": "datetimeDiff(\"2022-02-01\", \"2022-03-01\", \"month\")",
"description": "Returns the difference between two datetimes in some unit of time."
},
{
"name": "datetimeSubtract",
"type": "Function",
"syntax": "datetimeSubtract(column, amount, unit)",
"example": "datetimeSubtract(\"2021-03-25\", 1, \"month\")",
"description": "Subtracts some unit of time from a date or timestamp value."
},
{
"name": "day",
"type": "Function",
"syntax": "day([datetime column])",
"example": "day(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the day of the month as an integer."
},
{
"name": "hour",
"type": "Function",
"syntax": "hour([datetime column])",
"example": "hour(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the hour as an integer (0-23)."
},
{
"name": "interval",
"type": "Function",
"syntax": "interval(column, number, text)",
"example": "interval([Created At], -1, \"month\")",
"description": "Checks a date column’s values to see if they’re within the relative range."
},
{
"name": "minute",
"type": "Function",
"syntax": "minute([datetime column])",
"example": "minute(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the minute as an integer (0-59)."
},
{
"name": "month",
"type": "Function",
"syntax": "month([datetime column])",
"example": "month(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the month number (1-12) as an integer."
},
{
"name": "now",
"type": "Function",
"syntax": "now",
"example": "now",
"description": "Returns the current date and time using your Metabase report timezone."
},
{
"name": "quarter",
"type": "Function",
"syntax": "quarter([datetime column])",
"example": "quarter(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the number of the quarter in a year (1-4) as an integer."
},
{
"name": "relativeDateTime",
"type": "Function",
"syntax": "relativeDateTime(number, text)",
"example": "[Orders → Created At] < relativeDateTime(-30, \"day\")",
"description": "Gets a timestamp relative to the current time."
},
{
"name": "second",
"type": "Function",
"syntax": "second([datetime column])",
"example": "second(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the number of seconds in the minute (0-59) as an integer."
},
{
"name": "week",
"type": "Function",
"syntax": "week(column, mode)",
"example": "week(\"2021-03-25T12:52:37\")",
"description": "Takes a datetime and returns the week as an integer."
},
{
"name": "weekday",
"type": "Function",
"syntax": "weekday(column)",
"example": "weekday([Created At])",
"description": "Takes a datetime and returns an integer (1-7) with the number of the day of the week."
},
{
"name": "offset",
"type": "Aggregate",
"syntax": "Offset(expression, rowOffset)",
"example": "Offset(Sum([Total]), -1)",
"description": "Returns the value of an expression in a different row. The rowOffset is the number relative to the current row. For example, -1 for the previous row, or 1 for the next row."
}
]
export function searchCustomExpressions(userDescription: string) {
// TODO: Implement to get the most relevant expressions. For now we just return the whole list.
return CUSTOM_EXPRESSIONS_LIST;
}

View File

@@ -0,0 +1,116 @@
import { searchCustomExpressions } from './custom-expressions'
const CHAT_API_URL = 'http://localhost:8000/v1/chat/'
export const SYSTEM_MESSAGE = `
# Task
You are a Metabase AI assistant that knows the syntax of the Metabase GUI editor expressions.
Only use the expressions provided by the users. Make sure to use the correct syntax as provided in the examples.
Given a user description of what they want to do using a custom expression or aggregation in Metabase, you need to suggest the correct expression to use. The description can be in natural language or any other syntax (e.g. SQL, GUI editor syntax).
The user will also provide you with GUI editor expressions / functions that you are allowed to use and columns that are available in the data.
Column references are always in square brackets, e.g. [Quantity].
"Count" can never be used with arguments, e.g. "Count([Quantity])" is not allowed.
You know that for adding a custom column, you cannot use expressions of type "Aggregate" but only of type "Function".
For summarizing data, you can use both types of expressions but they need to include at least one "Aggregate".
# Response format
You always respond with a valid JSON wrapped in <json> tags in the following format:
<json>
{
"title": "A meaningful title for the column",
"expression": "The expression to use in the Metabase GUI editor"
}
</json>
DO NOT add any clarifying information - only respond with the JSON array.
# Examples
How many users have signed up
<json>
{
"title": "Users signed up",
"expression": "Distinct([User ID])"
}
</json>
How many orders
<json>
{
"title": "Orders",
"expression": "Count"
}
</json>
SUM(AMOUNT) / COUNT(ORDERS)
<json>
{
"title": "Average order value",
"expression": "Sum([Amount]) / Count"
}
</json>
`
export function getUserMessage(expressionDescription: string, operation: string, availableData: string, availableExpressions: string) {
return `
The user wants to ${operation} and has provided the following description:
\`\`\`${expressionDescription}\`\`\`
## Available data:
${availableData}
## Available expressions
${availableExpressions}
`
}
export async function generateSummarizeExpression(description: string, availableData: string) {
const MODEL = 'meta.llama3-70b-instruct-v1:0'
const data = {
"model": MODEL,
"messages": [
{
"role": "system",
"content": SYSTEM_MESSAGE
},
{
"role": "user",
"content": getUserMessage(
description,
'summarize',
availableData,
JSON.stringify(searchCustomExpressions(description))
)
}
]
}
// use fetch to call the API and store the response in the `response` variable
const response = await fetch(CHAT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
const responseJson = await response.json()
console.log('Response:', responseJson.response)
console.log('Tokens:', responseJson.metadata.usage.total)
// Extract JSON between <json> tags and parse it to a JSON object
const jsonStr = responseJson.response
const jsonStart = jsonStr.indexOf('<json>') + '<json>'.length
const jsonEnd = jsonStr.indexOf('</json>')
const json = JSON.parse(jsonStr.slice(jsonStart, jsonEnd))
console.log('AI response:', json)
return json
}

View File

@@ -18,6 +18,7 @@ import { isExpression } from "metabase-lib/v1/expressions";
import { diagnose } from "metabase-lib/v1/expressions/diagnostics";
import { format } from "metabase-lib/v1/expressions/format";
import { processSource } from "metabase-lib/v1/expressions/process";
import { generateSummarizeExpression } from "metabase-lib/v1/ai/generate-expression";
import type {
GroupName,
SuggestArgs,
@@ -35,6 +36,7 @@ import type { State } from "metabase-types/store";
import { ExpressionEditorHelpText } from "../ExpressionEditorHelpText";
import { ExpressionEditorSuggestions } from "../ExpressionEditorSuggestions";
import { Button, Loader, Textarea } from "metabase/ui";
import ExpressionMode from "../ExpressionMode";
import {
@@ -46,6 +48,13 @@ import {
ace.config.set("basePath", "/assets/ui/");
ace.config.set("useStrictCSP", true);
interface ExpressionEditorTextfieldState {
// ... existing state properties
autoSuggestedExpression: string;
loadingSuggestions: boolean;
}
export type SuggestionFooter = {
footer: true;
name: string;
@@ -130,12 +139,14 @@ interface ExpressionEditorTextfieldProps {
onChange: (
expression: Expression | null,
expressionClause: Lib.ExpressionClause | null,
name: string,
) => void;
onError: (error: ErrorWithMessage | null) => void;
onBlankChange: (isBlank: boolean) => void;
onCommit: (
expression: Expression | null,
expressionClause: Lib.ExpressionClause | null,
name: string,
) => void;
helpTextTarget: RefObject<HTMLElement>;
showMetabaseLinks: boolean;
@@ -144,6 +155,7 @@ interface ExpressionEditorTextfieldProps {
interface ExpressionEditorTextfieldState {
source: string;
suggestedTitle: string;
suggestions: (Suggestion | SuggestionFooter | SuggestionShortcut)[];
highlightedSuggestionIndex: number;
isFocused: boolean;
@@ -201,6 +213,9 @@ function transformPropsToState(
isFocused: false,
errorMessage: null,
hasChanges: false,
suggestedTitle: "",
autoSuggestedExpression: '',
loadingSuggestions: false,
};
}
@@ -325,12 +340,16 @@ class ExpressionEditorTextfield extends React.Component<
}
}
componentDidUpdate() {
componentDidUpdate(prevProps: ExpressionEditorTextfieldProps) {
const { textAreaId } = this.props;
if (this.input.current && textAreaId) {
const textArea = this.input.current.editor.textInput.getElement?.();
textArea?.setAttribute?.("id", textAreaId);
}
if (this.props.name && prevProps.name !== this.props.name) {
// debounce parsing the source with LLM
this.debouncedParseSourceWithLLM();
}
}
componentWillUnmount() {
@@ -475,31 +494,7 @@ class ExpressionEditorTextfield extends React.Component<
return;
}
const { onChange, onError } = this.props;
this.clearSuggestions();
const errorMessage = this.diagnoseExpression();
this.setState({ errorMessage });
// whenever our input blurs we push the updated expression to our parent if valid
if (errorMessage) {
onError(errorMessage);
} else {
const compiledExpression = this.compileExpression();
if (compiledExpression) {
const { expression, expressionClause } = compiledExpression;
if (!isExpression(expression)) {
console.warn("isExpression=false", expression);
}
onChange(expression, expressionClause);
} else {
onError({ message: t`Invalid expression` });
}
}
this.triggerOnChangeEvent();
};
clearSuggestions() {
@@ -541,6 +536,7 @@ class ExpressionEditorTextfield extends React.Component<
if (!source || source.length === 0) {
return null;
}
console.log('Compiling expression', source)
const { expression, expressionClause } = processSource({
name,
source,
@@ -579,6 +575,84 @@ class ExpressionEditorTextfield extends React.Component<
});
}
triggerOnChangeEvent = () => {
const { onChange, onError } = this.props;
this.clearSuggestions();
const errorMessage = this.diagnoseExpression();
this.setState({ errorMessage });
// whenever our input blurs we push the updated expression to our parent if valid
if (errorMessage) {
onError(errorMessage);
} else {
const compiledExpression = this.compileExpression();
if (compiledExpression) {
const { expression, expressionClause } = compiledExpression;
if (!isExpression(expression)) {
console.warn("isExpression=false", expression);
}
const name = this.state.suggestedTitle || this.props.name;
onChange(expression, expressionClause, name);
} else {
onError({ message: t`Invalid expression` });
}
}
}
// Function to call an API with the `source` content. The API will return an updated source content
parseSourceWithLLM = async () => {
console.log('Parsing with LLM')
// Set the loading state to true
this.setState({ loadingSuggestions: true });
const { query, stageIndex, expressionIndex, metadata } = this.props;
const columnMetadata = Lib.expressionableColumns(query, stageIndex, expressionIndex);
const displayInfos = columnMetadata.map(column => {
return Lib.displayInfo(query, stageIndex, column);
});
const availableData = Object.values(metadata.tables).map(table => {
if (!table.fields) {
return;
}
const columns = [];
for (const field of table.fields) {
const displayInfo = displayInfos.find(displayInfo => displayInfo.name === field.name && displayInfo.table?.name === table.name);
if (!displayInfo) {
continue;
}
columns.push({
name: `[${displayInfo.longDisplayName}]`,
database_type: field.effective_type,
});
}
return {
table: table.name,
description: table.description,
columns,
};
});
const response = await generateSummarizeExpression(this.props.name, JSON.stringify(availableData));
try {
this.setState({
autoSuggestedExpression: response.expression,
});
} catch (error) {
console.error("Error setting state:", error);
}
this.setState({ loadingSuggestions: false });
}
debouncedParseSourceWithLLM = _.debounce(this.parseSourceWithLLM, 1000);
commitExpression() {
const {
query,
@@ -609,7 +683,8 @@ class ExpressionEditorTextfield extends React.Component<
const { expression, expressionClause } = compiledExpression;
if (isExpression(expression)) {
onCommit(expression, expressionClause);
const name = this.state.suggestedTitle;
onCommit(expression, expressionClause, name);
}
} else {
onError({ message: t`Invalid expression` });
@@ -729,10 +804,18 @@ class ExpressionEditorTextfield extends React.Component<
isFocused,
highlightedSuggestionIndex,
helpText,
autoSuggestedExpression
} = this.state;
return (
<div ref={this.helpTextTarget}>
{autoSuggestedExpression && (
<Button onClick={() => this.handleExpressionChange(autoSuggestedExpression)}>
{autoSuggestedExpression}
</Button>
)}
<ExpressionEditorSuggestions
query={query}
stageIndex={stageIndex}
@@ -743,6 +826,7 @@ class ExpressionEditorTextfield extends React.Component<
open={isFocused}
ref={this.popupMenuTarget}
>
<EditorContainer
isFocused={isFocused}
hasError={Boolean(errorMessage)}
@@ -767,6 +851,13 @@ class ExpressionEditorTextfield extends React.Component<
onCursorChange={this.handleCursorChange}
width="100%"
/>
{ this.state.loadingSuggestions && (
<div style={{ fontSize: "0.8rem" }}>
<Loader size="1rem" />
Loading suggestions...
</div>
)}
</EditorContainer>
</ExpressionEditorSuggestions>
{errorMessage && hasChanges && (

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { useState } from "react";
import type { ReactNode} from "react";
import { useEffect, useState, useRef } from "react";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
@@ -7,6 +7,7 @@ import { isNotNull } from "metabase/lib/types";
import { Button, TextInput } from "metabase/ui";
import * as Lib from "metabase-lib";
import { isExpression } from "metabase-lib/v1/expressions";
import { debounce } from 'lodash';
import type { Expression } from "metabase-types/api";
import {
@@ -95,7 +96,15 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
const isValid =
!error && isValidName && (isValidExpression || isValidExpressionClause);
const handleCommit = (
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}, [name]);
const handleCommit = (
expression: Expression | null,
clause: Clause | Lib.ExpressionClause | null,
) => {
@@ -192,6 +201,30 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
return (
<Container data-testid="expression-editor">
{header}
{withName && (
<FieldWrapper>
<FieldLabel htmlFor="expression-name">{t`Name`}</FieldLabel>
<TextInput
classNames={{
input: CS.textBold,
}}
id="expression-name"
data-testid="expression-name"
type="text"
value={name}
placeholder={t`Something nice and descriptive`}
w="100%"
radius="md"
ref={nameInputRef}
onChange={event => setName(event.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
handleCommit(expression, clause);
}
}}
/>
</FieldWrapper>
)}
<ExpressionFieldWrapper>
<FieldLabel htmlFor="expression-content">
{t`Expression`}
@@ -230,29 +263,6 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
].filter(Boolean)}
/>
</ExpressionFieldWrapper>
{withName && (
<FieldWrapper>
<FieldLabel htmlFor="expression-name">{t`Name`}</FieldLabel>
<TextInput
classNames={{
input: CS.textBold,
}}
id="expression-name"
data-testid="expression-name"
type="text"
value={name}
placeholder={t`Something nice and descriptive`}
w="100%"
radius="md"
onChange={event => setName(event.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
handleCommit(expression, clause);
}
}}
/>
</FieldWrapper>
)}
<Footer>
<ActionButtonsWrapper>

View File

@@ -31,7 +31,7 @@
(base64-encode
(.digest (doto (MessageDigest/getInstance "SHA-256")
(.update (.getBytes (slurp (io/resource resource-filename))))))))]
(mapv file-hash [ ;; inline script in index.html that sets `MetabaseBootstrap` and the like
(mapv file-hash [;; inline script in index.html that sets `MetabaseBootstrap` and the like
"frontend_client/inline_js/index_bootstrap.js"
;; inline script in init.html
"frontend_client/inline_js/init.js"])))
@@ -60,22 +60,22 @@
(str/join
(for [[k vs] {:default-src ["'none'"]
:script-src (concat
["'self'"
"https://maps.google.com"
"https://accounts.google.com"
(when (public-settings/anon-tracking-enabled)
"https://www.google-analytics.com")
["'self'"
"https://maps.google.com"
"https://accounts.google.com"
(when (public-settings/anon-tracking-enabled)
"https://www.google-analytics.com")
;; for webpack hot reloading
(when config/is-dev?
"http://localhost:8080")
(when config/is-dev?
"http://localhost:8080")
;; for react dev tools to work in Firefox until resolution of
;; https://github.com/facebook/react/issues/17997
(when config/is-dev?
"'unsafe-inline'")]
;; CLJS REPL
(when config/is-dev?
["'unsafe-eval'"
"http://localhost:9630"])
"'unsafe-inline'")]
;; CLJS REPL
(when config/is-dev?
["'unsafe-eval'"
"http://localhost:9630"])
(when-not config/is-dev?
(map (partial format "'sha256-%s'") inline-js-hashes)))
:child-src ["'self'"
@@ -107,7 +107,9 @@
(snowplow/snowplow-url))
;; Webpack dev server
(when config/is-dev?
"*:8080 ws://*:8080")
"http://localhost:8000")
(when config/is-dev?
"*:8000 ws://*:8000")
;; CLJS REPL
(when config/is-dev?
"ws://*:9630")]
@@ -139,7 +141,6 @@
:domain domain
:port port})))))
(defn approved-domain?
"Checks if the domain is compatible with the reference one"
[domain reference-domain]
@@ -162,10 +163,10 @@
(= port reference-port)))
(defn parse-approved-origins
"Parses the space separated string of approved origins"
[approved-origins-raw]
(let [urls (str/split approved-origins-raw #" +")]
(keep parse-url urls)))
"Parses the space separated string of approved origins"
[approved-origins-raw]
(let [urls (str/split approved-origins-raw #" +")]
(keep parse-url urls)))
(defn approved-origin?
"Returns true if `origin` should be allowed for CORS based on the `approved-origins`"
@@ -175,19 +176,19 @@
(let [approved-list (parse-approved-origins approved-origins-raw)
origin (parse-url raw-origin)]
(some (fn [approved-origin]
(and
(approved-domain? (:domain origin) (:domain approved-origin))
(approved-protocol? (:protocol origin) (:protocol approved-origin))
(approved-port? (:port origin) (:port approved-origin))))
approved-list)))))
(and
(approved-domain? (:domain origin) (:domain approved-origin))
(approved-protocol? (:protocol origin) (:protocol approved-origin))
(approved-port? (:port origin) (:port approved-origin))))
approved-list)))))
(defn- access-control-headers
[origin]
(merge
(when
(approved-origin? origin (embedding-app-origin))
{"Access-Control-Allow-Origin" origin
"Vary" "Origin"})
{"Access-Control-Allow-Origin" origin
"Vary" "Origin"})
{"Access-Control-Allow-Headers" "*"
"Access-Control-Expose-Headers" "X-Metabase-Anti-CSRF-Token"}))
@@ -215,7 +216,7 @@
{"X-Frame-Options" (if (embedding-app-origin)
(format "ALLOW-FROM %s" (first-embedding-app-origin))
"DENY")})
{ ;; Tell browser to block suspected XSS attacks
{;; Tell browser to block suspected XSS attacks
"X-XSS-Protection" "1; mode=block"
;; Prevent Flash / PDF files from including content from site.
"X-Permitted-Cross-Domain-Policies" "none"