diff --git a/frontend/src/metabase/admin/datamodel/components/FieldRemapping.jsx b/frontend/src/metabase/admin/datamodel/components/FieldRemapping.jsx index 034bde5d2ca98821f371996a04783894a6424c7e..abad37e04532abcf0e057e8228bc31a3ce74c0a1 100644 --- a/frontend/src/metabase/admin/datamodel/components/FieldRemapping.jsx +++ b/frontend/src/metabase/admin/datamodel/components/FieldRemapping.jsx @@ -355,6 +355,8 @@ export class ValueRemappings extends React.Component { const mappedString = mappedOrUndefined !== undefined ? mappedOrUndefined.toString() + : original === null + ? "null" : original.toString(); return [original, mappedString]; diff --git a/frontend/test/__support__/cypress.js b/frontend/test/__support__/cypress.js index 7c935189819035bcfdc6139eaa29a0d304954111..09e32597a9eb8f53ec7c8c24529f7616146bb58e 100644 --- a/frontend/test/__support__/cypress.js +++ b/frontend/test/__support__/cypress.js @@ -109,21 +109,25 @@ export function typeAndBlurUsingLabel(label, value) { Cypress.on("uncaught:exception", (err, runnable) => false); -export function withSampleDataset(f) { - cy.request("GET", "/api/database/1/metadata").then(({ body }) => { - const SAMPLE_DATASET = {}; +export function withDatabase(databaseId, f) { + cy.request("GET", `/api/database/${databaseId}/metadata`).then(({ body }) => { + const database = {}; for (const table of body.tables) { const fields = {}; for (const field of table.fields) { fields[field.name] = field.id; } - SAMPLE_DATASET[table.name] = fields; - SAMPLE_DATASET[table.name + "_ID"] = table.id; + database[table.name] = fields; + database[table.name + "_ID"] = table.id; } - f(SAMPLE_DATASET); + f(database); }); } +export function withSampleDataset(f) { + return withDatabase(1, f); +} + export function visitAlias(alias) { cy.get(alias).then(url => { cy.visit(url); diff --git a/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js b/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js index a51f4681d98c3fc7ac65a0c190048a2bcf75e84f..b798e8b0a1c98751f89dcad3dc3a53510e93511f 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js @@ -2,7 +2,9 @@ import { signInAsAdmin, restore, withSampleDataset, + withDatabase, visitAlias, + popover, } from "__support__/cypress"; describe("scenarios > admin > datamodel > field", () => { @@ -159,5 +161,38 @@ describe("scenarios > admin > datamodel > field", () => { cy.contains("Custom mapping"); cy.get('input[value="foo"]'); }); + + it("allows 'Custom mapping' null values", () => { + restore("withSqlite"); + signInAsAdmin(); + const dbId = 2; + withDatabase( + dbId, + ({ number_with_nulls: { num }, number_with_nulls_ID }) => + cy.visit( + `/admin/datamodel/database/${dbId}/table/${number_with_nulls_ID}/${num}/general`, + ), + ); + + // change to custom mapping + cy.findByText("Use original value").click(); + popover() + .findByText("Custom mapping") + .click(); + + // update text for nulls from "null" to "nothin" + cy.get("input[value=null]") + .clear() + .type("nothin"); + cy.findByText("Save").click(); + cy.findByText("Saved!"); + + // check that it appears in QB + cy.visit("/question/new"); + cy.findByText("Simple question").click(); + cy.findByText("sqlite").click(); + cy.findByText("Number With Nulls").click(); + cy.findByText("nothin"); + }); }); }); diff --git a/frontend/test/snapshot-creators/default.cy.snap.js b/frontend/test/snapshot-creators/default.cy.snap.js index 9af881e279422f80f2642ad5429afb0bcf2c3e8e..61b643b4c781e1c3168e8942c22c81614638bb00 100644 --- a/frontend/test/snapshot-creators/default.cy.snap.js +++ b/frontend/test/snapshot-creators/default.cy.snap.js @@ -3,156 +3,198 @@ import { restore, USERS, withSampleDataset, + signInAsAdmin, } from "__support__/cypress"; -describe("default", () => { - it("default", () => { - snapshot("blank"); - setup(); - updateSettings(); - addUsersAndGroups(); - withSampleDataset(SAMPLE_DATASET => { - createQuestionAndDashboard(SAMPLE_DATASET); +describe("snapshots", () => { + describe("default", () => { + it("default", () => { + snapshot("blank"); + setup(); + updateSettings(); + addUsersAndGroups(); + withSampleDataset(SAMPLE_DATASET => { + createQuestionAndDashboard(SAMPLE_DATASET); + }); + snapshot("default"); + restore("blank"); }); - snapshot("default"); - restore("blank"); }); -}); -function makeUserObject(name, groupIds) { - return { - first_name: USERS[name].first_name, - last_name: USERS[name].last_name, - email: USERS[name].username, - password: USERS[name].password, - group_ids: groupIds, - }; -} - -function setup() { - cy.request("GET", "/api/session/properties").then(({ body: properties }) => { - cy.request("POST", "/api/setup", { - token: properties["setup-token"], - user: makeUserObject("admin"), - prefs: { - site_name: "Epic Team", - allow_tracking: false, + function makeUserObject(name, groupIds) { + return { + first_name: USERS[name].first_name, + last_name: USERS[name].last_name, + email: USERS[name].username, + password: USERS[name].password, + group_ids: groupIds, + }; + } + + function setup() { + cy.request("GET", "/api/session/properties").then( + ({ body: properties }) => { + cy.request("POST", "/api/setup", { + token: properties["setup-token"], + user: makeUserObject("admin"), + prefs: { + site_name: "Epic Team", + allow_tracking: false, + }, + database: null, + }); }, - database: null, + ); + } + + function updateSettings() { + cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + cy.request("PUT", "/api/setting/enable-embedding", { value: true }); + cy.request("PUT", "/api/setting/embedding-secret-key", { + value: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", }); - }); -} -function updateSettings() { - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); - cy.request("PUT", "/api/setting/enable-embedding", { value: true }); - cy.request("PUT", "/api/setting/embedding-secret-key", { - value: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - }); + // update the Sample db connection string so it is valid in both CI and locally + cy.request("GET", "/api/database/1").then(response => { + response.body.details.db = + "./resources/sample-dataset.db;USER=GUEST;PASSWORD=guest"; + cy.request("PUT", "/api/database/1", response.body); + }); + } - // update the Sample db connection string so it is valid in both CI and locally - cy.request("GET", "/api/database/1").then(response => { - response.body.details.db = - "./resources/sample-dataset.db;USER=GUEST;PASSWORD=guest"; - cy.request("PUT", "/api/database/1", response.body); - }); -} - -const ALL_USERS_GROUP = 1; -const COLLECTION_GROUP = 4; -const DATA_GROUP = 5; - -function addUsersAndGroups() { - // groups - cy.request("POST", "/api/permissions/group", { name: "collection" }); // 4 - cy.request("POST", "/api/permissions/group", { name: "data" }); // 5 - - // additional users - cy.request( - "POST", - "/api/user", - makeUserObject("normal", [ALL_USERS_GROUP, COLLECTION_GROUP, DATA_GROUP]), - ); - cy.request( - "POST", - "/api/user", - makeUserObject("nodata", [ALL_USERS_GROUP, COLLECTION_GROUP]), - ); - cy.request( - "POST", - "/api/user", - makeUserObject("nocollection", [ALL_USERS_GROUP, DATA_GROUP]), - ); - cy.request("POST", "/api/user", makeUserObject("none", [ALL_USERS_GROUP])); - - // Make a call to `/api/user` because some things (personal collections) get created there - cy.request("GET", "/api/user"); - - // permissions - cy.request("PUT", "/api/permissions/graph", { - revision: 0, - groups: { - [ALL_USERS_GROUP]: { "1": { schemas: "none", native: "none" } }, - [DATA_GROUP]: { "1": { schemas: "all", native: "write" } }, - [COLLECTION_GROUP]: { "1": { schemas: "none", native: "none" } }, - }, - }); - cy.request("PUT", "/api/collection/graph", { - revision: 0, - groups: { - [ALL_USERS_GROUP]: { root: "none" }, - [DATA_GROUP]: { root: "none" }, - [COLLECTION_GROUP]: { root: "write" }, - }, - }); -} - -function createQuestionAndDashboard({ ORDERS, ORDERS_ID }) { - // question 1: Orders - cy.request("POST", "/api/card", { - name: "Orders", - display: "table", - visualization_settings: {}, - dataset_query: { - database: 1, - query: { "source-table": ORDERS_ID }, - type: "query", - }, - }); + const ALL_USERS_GROUP = 1; + const COLLECTION_GROUP = 4; + const DATA_GROUP = 5; - // question 2: Orders, Count - cy.request("POST", "/api/card", { - name: "Orders, Count", - display: "table", - visualization_settings: {}, - dataset_query: { - database: 1, - query: { "source-table": ORDERS_ID, aggregation: [["count"]] }, - type: "query", - }, - }); + function addUsersAndGroups() { + // groups + cy.request("POST", "/api/permissions/group", { name: "collection" }); // 4 + cy.request("POST", "/api/permissions/group", { name: "data" }); // 5 + + // additional users + cy.request( + "POST", + "/api/user", + makeUserObject("normal", [ALL_USERS_GROUP, COLLECTION_GROUP, DATA_GROUP]), + ); + cy.request( + "POST", + "/api/user", + makeUserObject("nodata", [ALL_USERS_GROUP, COLLECTION_GROUP]), + ); + cy.request( + "POST", + "/api/user", + makeUserObject("nocollection", [ALL_USERS_GROUP, DATA_GROUP]), + ); + cy.request("POST", "/api/user", makeUserObject("none", [ALL_USERS_GROUP])); - cy.request("POST", "/api/card", { - name: "Orders, Count, Grouped by Created At (year)", - dataset_query: { - type: "query", - query: { - "source-table": ORDERS_ID, - aggregation: [["count"]], - breakout: [["datetime-field", ["field-id", ORDERS.CREATED_AT], "year"]], + // Make a call to `/api/user` because some things (personal collections) get created there + cy.request("GET", "/api/user"); + + // permissions + cy.request("PUT", "/api/permissions/graph", { + revision: 0, + groups: { + [ALL_USERS_GROUP]: { "1": { schemas: "none", native: "none" } }, + [DATA_GROUP]: { "1": { schemas: "all", native: "write" } }, + [COLLECTION_GROUP]: { "1": { schemas: "none", native: "none" } }, }, - database: 1, - }, - display: "line", - visualization_settings: {}, - }); + }); + cy.request("PUT", "/api/collection/graph", { + revision: 0, + groups: { + [ALL_USERS_GROUP]: { root: "none" }, + [DATA_GROUP]: { root: "none" }, + [COLLECTION_GROUP]: { root: "write" }, + }, + }); + } + + function createQuestionAndDashboard({ ORDERS, ORDERS_ID }) { + // question 1: Orders + cy.request("POST", "/api/card", { + name: "Orders", + display: "table", + visualization_settings: {}, + dataset_query: { + database: 1, + query: { "source-table": ORDERS_ID }, + type: "query", + }, + }); - // dashboard 1: Orders in a dashboard - cy.request("POST", "/api/dashboard", { name: "Orders in a dashboard" }); - cy.request("POST", `/api/dashboard/1/cards`, { cardId: 1 }); + // question 2: Orders, Count + cy.request("POST", "/api/card", { + name: "Orders, Count", + display: "table", + visualization_settings: {}, + dataset_query: { + database: 1, + query: { "source-table": ORDERS_ID, aggregation: [["count"]] }, + type: "query", + }, + }); - // dismiss the "it's ok to play around" modal - Object.values(USERS).map((_, index) => - cy.request("PUT", `/api/user/${index + 1}/qbnewb`, {}), - ); -} + cy.request("POST", "/api/card", { + name: "Orders, Count, Grouped by Created At (year)", + dataset_query: { + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + ["datetime-field", ["field-id", ORDERS.CREATED_AT], "year"], + ], + }, + database: 1, + }, + display: "line", + visualization_settings: {}, + }); + + // dashboard 1: Orders in a dashboard + cy.request("POST", "/api/dashboard", { name: "Orders in a dashboard" }); + cy.request("POST", `/api/dashboard/1/cards`, { cardId: 1 }); + + // dismiss the "it's ok to play around" modal + Object.values(USERS).map((_, index) => + cy.request("PUT", `/api/user/${index + 1}/qbnewb`, {}), + ); + } + + // TODO: It'd be nice to have one file per snapshot. + // To do that we need to enforce execution order among them. + describe("withSqlite", () => { + it("withSqlite", () => { + restore("default"); + signInAsAdmin(); + cy.request("POST", "/api/database", { + engine: "sqlite", + name: "sqlite", + details: { db: "./resources/sqlite-fixture.db" }, + auto_run_queries: true, + is_full_sync: true, + schedules: { + cache_field_values: { + schedule_day: null, + schedule_frame: null, + schedule_hour: 0, + schedule_type: "daily", + }, + metadata_sync: { + schedule_day: null, + schedule_frame: null, + schedule_hour: null, + schedule_type: "hourly", + }, + }, + }); + cy.request("POST", "/api/database/2/sync_schema"); + cy.request("POST", "/api/database/2/rescan_values"); + cy.wait(1000); // wait for sync + snapshot("withSqlite"); + restore("blank"); + }); + }); +}); diff --git a/resources/sqlite-fixture.db b/resources/sqlite-fixture.db new file mode 100644 index 0000000000000000000000000000000000000000..b390f7cc7d33396a245eb880ba662f8e802683e3 Binary files /dev/null and b/resources/sqlite-fixture.db differ