Initial work on new palette/theme
Nav menu overhaul New folder for material overrides Custom font classes Fix global font, add back resets, and update link colors Fix project list layout Expose material typography tokens as css classes Add button story and scss file for storybook Implementation of top app bar New project multiselect New timerange picker e2e fixes responsive layout fixes
This commit is contained in:
1 parent
0d798fabd4
commit
a797aa6c15
277 files changed
+5936
-9298
No files matched your search
+9
-1
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"htmlWhitespaceSensitivity": "strict"
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { withColorScheme } from "./decorators/with-color-scheme.decorator";
|
||||
})
|
||||
class ParentComponent {
|
||||
constructor(private matIconRegistry: MatIconRegistry) {
|
||||
this.matIconRegistry.setDefaultFontSetClass("material-symbols-outlined");
|
||||
this.matIconRegistry.setDefaultFontSetClass("material-symbols-filled");
|
||||
document.documentElement.classList.add("light");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
setupNodeEvents(on, config) {
|
||||
return require("./cypress/plugins/index.js")(on, config);
|
||||
},
|
||||
viewportWidth: 1280,
|
||||
experimentalRunAllSpecs: true,
|
||||
baseUrl: "http://localhost:4200",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import { changePassword, user } from "../fixtures/users";
|
||||
import { changePassword, seededUser1 } from "../fixtures/users";
|
||||
|
||||
describe("Change Password", () => {
|
||||
beforeEach(() => {
|
||||
@@ -24,7 +24,7 @@ describe("Change Password", () => {
|
||||
});
|
||||
|
||||
it("Should confirm the user's password was saved", () => {
|
||||
cy.get("input[formcontrolname=current_password]").type(user.password);
|
||||
cy.get("input[formcontrolname=current_password]").type(seededUser1.password);
|
||||
cy.get("input[formcontrolname=new_password]").type(
|
||||
changePassword.new_password
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requestLogin, seedBackend } from "./utils.cy";
|
||||
import { project } from "../fixtures/variables";
|
||||
import { seededProject1 } from "../fixtures/variables";
|
||||
|
||||
describe("Home page", () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,6 +9,6 @@ describe("Home page", () => {
|
||||
it("should show a list of projects", () => {
|
||||
requestLogin();
|
||||
cy.visit("/");
|
||||
cy.contains(project.name);
|
||||
cy.contains(seededProject1.name);
|
||||
});
|
||||
});
|
||||
+61
-61
@@ -1,5 +1,5 @@
|
||||
import { seedBackend, requestLogin, getDSN, uniqueId } from "./utils.cy";
|
||||
import { organization, project } from "../fixtures/variables";
|
||||
import { seededOrg, seededProject1 } from "../fixtures/variables";
|
||||
|
||||
describe("Issues Page", () => {
|
||||
beforeEach(() => {
|
||||
@@ -13,85 +13,85 @@ describe("Issues Page", () => {
|
||||
function () {
|
||||
cy.intercept(
|
||||
"GET",
|
||||
"/api/0/organizations/*/issues/?query=is%3Aunresolved"
|
||||
"/api/0/organizations/*/issues/?query=is%3Aunresolved",
|
||||
).as("initialIssuesRequest");
|
||||
cy.visit(`/${organization.slug}/issues`);
|
||||
cy.visit(`/${seededOrg.slug}/issues`);
|
||||
cy.wait("@initialIssuesRequest");
|
||||
|
||||
cy.get("#selectAll").click();
|
||||
cy.get("[data-cy=selectAll]").click();
|
||||
cy.contains("Select all 55 issues that match this query");
|
||||
cy.get("#bulkUpdateProject").click();
|
||||
cy.get("[data-cy=bulkUpdateProject]").click();
|
||||
cy.contains("All 55 issues are currently selected");
|
||||
|
||||
cy.intercept("PUT", "/api/0/organizations/*/issues/").as(
|
||||
"issuesUpdateRequest"
|
||||
"issuesUpdateRequest",
|
||||
);
|
||||
cy.get("#bulkMarkResolved").click();
|
||||
cy.get("[data-cy='bulkMarkResolved']").click();
|
||||
cy.wait("@issuesUpdateRequest");
|
||||
cy.log(
|
||||
"To ensure this made a successful network call, we search for resolved events."
|
||||
"To ensure this made a successful network call, we search for resolved events.",
|
||||
);
|
||||
cy.intercept(
|
||||
"GET",
|
||||
"/api/0/organizations/*/issues/?query=is%3Aresolved"
|
||||
"/api/0/organizations/*/issues/?query=is%3Aresolved",
|
||||
).as("resolvedIssuesRequest");
|
||||
cy.get('[data-cy="list-search-field"]').clear().type("is:resolved");
|
||||
cy.get('[data-cy="list-search-form"]').submit();
|
||||
cy.wait("@resolvedIssuesRequest");
|
||||
|
||||
cy.get('[data-cy="list-title"]').contains("Issues (55)");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("should test event detail request body structured and raw views", function () {
|
||||
const testEvent = {
|
||||
message: "Test Event with POST Body",
|
||||
level: "error",
|
||||
event_id: uniqueId(),
|
||||
platform: "javascript",
|
||||
sdk: {
|
||||
name: "sentry.javascript.browser",
|
||||
packages: [{ name: "npm:@sentry/browser", version: "5.29.2" }],
|
||||
version: "5.29.2",
|
||||
},
|
||||
timestamp: Date.now() / 1000,
|
||||
request: {
|
||||
url: "http://localhost:4201/api/test",
|
||||
method: "POST",
|
||||
data: {
|
||||
a: "foo",
|
||||
b: [1, 2]
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0"
|
||||
}
|
||||
}
|
||||
};
|
||||
cy.visit(`/${organization.slug}/issues`);
|
||||
cy.wait(1000);
|
||||
cy.get("gt-project-filter-bar mat-expansion-panel-header").click();
|
||||
cy.get("gt-project-filter-bar").contains(project.name).click();
|
||||
cy.get("[data-test-dsn]")
|
||||
.invoke("val")
|
||||
.then((dsn) => {
|
||||
const url = getDSN(dsn as string);
|
||||
cy.request("POST", url, testEvent);
|
||||
cy.wait(3000);
|
||||
cy.visit(`/${organization.slug}/issues`);
|
||||
cy.wait(2000);
|
||||
cy.get(".title-cell").find("a").first().click();
|
||||
cy.wait(2000);
|
||||
cy.get('mat-button-toggle[value="structured"]').should('have.class', 'mat-button-toggle-checked');
|
||||
cy.get('gt-entry-data').contains('a').parent().should('contain', 'foo');
|
||||
cy.get('gt-entry-data').contains('b').parent().should('contain', '[1,2]');
|
||||
cy.get('mat-button-toggle[value="raw"]').click();
|
||||
cy.wait(500);
|
||||
cy.get('mat-button-toggle[value="raw"]').should('have.class', 'mat-button-toggle-checked');
|
||||
cy.get('pre code[gtPrism]').should('contain', '"a": "foo"');
|
||||
cy.get('pre code[gtPrism]').should('contain', '"b": [');
|
||||
cy.get('pre code[gtPrism]').should('contain', '1,');
|
||||
cy.get('pre code[gtPrism]').should('contain', '2');
|
||||
});
|
||||
});
|
||||
// it("should test event detail request body structured and raw views", function () {
|
||||
// const testEvent = {
|
||||
// message: "Test Event with POST Body",
|
||||
// level: "error",
|
||||
// event_id: uniqueId(),
|
||||
// platform: "javascript",
|
||||
// sdk: {
|
||||
// name: "sentry.javascript.browser",
|
||||
// packages: [{ name: "npm:@sentry/browser", version: "5.29.2" }],
|
||||
// version: "5.29.2",
|
||||
// },
|
||||
// timestamp: Date.now() / 1000,
|
||||
// request: {
|
||||
// url: "http://localhost:4201/api/test",
|
||||
// method: "POST",
|
||||
// data: {
|
||||
// a: "foo",
|
||||
// b: [1, 2]
|
||||
// },
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0"
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
// cy.visit(`/${organization.slug}/issues`);
|
||||
// cy.wait(1000);
|
||||
// cy.get("gt-project-filter-bar mat-expansion-panel-header").click();
|
||||
// cy.get("gt-project-filter-bar").contains(project.name).click();
|
||||
// cy.get("[data-test-dsn]")
|
||||
// .invoke("val")
|
||||
// .then((dsn) => {
|
||||
// const url = getDSN(dsn as string);
|
||||
// cy.request("POST", url, testEvent);
|
||||
// cy.wait(3000);
|
||||
// cy.visit(`/${organization.slug}/issues`);
|
||||
// cy.wait(2000);
|
||||
// cy.get(".title-cell").find("a").first().click();
|
||||
// cy.wait(2000);
|
||||
// cy.get('mat-button-toggle[value="structured"]').should('have.class', 'mat-button-toggle-checked');
|
||||
// cy.get('gt-entry-data').contains('a').parent().should('contain', 'foo');
|
||||
// cy.get('gt-entry-data').contains('b').parent().should('contain', '[1,2]');
|
||||
// cy.get('mat-button-toggle[value="raw"]').click();
|
||||
// cy.wait(500);
|
||||
// cy.get('mat-button-toggle[value="raw"]').should('have.class', 'mat-button-toggle-checked');
|
||||
// cy.get('pre code[gtPrism]').should('contain', '"a": "foo"');
|
||||
// cy.get('pre code[gtPrism]').should('contain', '"b": [');
|
||||
// cy.get('pre code[gtPrism]').should('contain', '1,');
|
||||
// cy.get('pre code[gtPrism]').should('contain', '2');
|
||||
// });
|
||||
// });
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { seedBackend } from "./utils.cy";
|
||||
import { user } from "../fixtures/users";
|
||||
import { seededUser1 } from "../fixtures/users";
|
||||
|
||||
describe("Login", () => {
|
||||
it("should show validation errors", () => {
|
||||
@@ -13,8 +13,8 @@ describe("Login", () => {
|
||||
seedBackend();
|
||||
|
||||
cy.visit("/login");
|
||||
cy.get("input[formcontrolname=email]").type(user.email);
|
||||
cy.get("input[formcontrolname=password]").type(user.password);
|
||||
cy.get("input[formcontrolname=email]").type(seededUser1.email);
|
||||
cy.get("input[formcontrolname=password]").type(seededUser1.password);
|
||||
cy.get("#submit").click();
|
||||
cy.url().should("eq", "http://localhost:4200/");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import { seededUser1, seededUser2 } from "../fixtures/users";
|
||||
|
||||
describe("Change Password", () => {
|
||||
beforeEach(() => {
|
||||
@@ -8,7 +9,7 @@ describe("Change Password", () => {
|
||||
});
|
||||
|
||||
it("should show one primary email address", () => {
|
||||
cy.get("gt-manage-emails").contains("cypresstest@example.com");
|
||||
cy.get("gt-manage-emails").contains(seededUser1.email);
|
||||
});
|
||||
|
||||
it("should manipulate the email list and do some validation", () => {
|
||||
@@ -24,17 +25,10 @@ describe("Change Password", () => {
|
||||
cy.get("#add-email-form").submit();
|
||||
cy.get("gt-manage-emails").contains("is already on the list");
|
||||
|
||||
// Email is not valid because mailgun says so. Comment out if problematic
|
||||
// const invalidEmail = "hello@a.aa";
|
||||
// cy.get("input[formcontrolname=email_address]").clear().type(invalidEmail);
|
||||
// cy.get("#add-email-form").submit();
|
||||
// cy.get("gt-manage-emails").contains("This is not a valid email address");
|
||||
|
||||
// Email is associated with another account
|
||||
const emailForOtherAccount = "cypresstest-other@example.com";
|
||||
cy.get("input[formcontrolname=email_address]")
|
||||
.clear()
|
||||
.type(emailForOtherAccount);
|
||||
.type(seededUser2.email);
|
||||
cy.get("#add-email-form").submit();
|
||||
cy.get("gt-manage-emails").contains("already exists");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requestLogin, seedBackend } from "./utils.cy";
|
||||
import { organization } from "../fixtures/variables";
|
||||
import { seededOrg } from "../fixtures/variables";
|
||||
|
||||
describe("Organization Settings", () => {
|
||||
beforeEach(() => {
|
||||
@@ -8,14 +8,18 @@ describe("Organization Settings", () => {
|
||||
});
|
||||
|
||||
it("updates the org name, deletes org and returns to empty org state", () => {
|
||||
cy.visit(`/${organization.slug}/settings`);
|
||||
cy.contains(organization.name);
|
||||
cy.get("input[formcontrolname=name]").clear().type(organization.otherOrg);
|
||||
const newOrgName = "e2etestobj-other-org"
|
||||
cy.visit(`/${seededOrg.slug}/settings`);
|
||||
cy.contains(seededOrg.name);
|
||||
cy.get("input[formcontrolname=name]").clear().type(newOrgName);
|
||||
cy.get("#update-org").click();
|
||||
cy.get("input[formcontrolname=name]").should('have.value', organization.otherOrg);
|
||||
cy.get("input[formcontrolname=name]").should(
|
||||
"have.value",
|
||||
newOrgName,
|
||||
);
|
||||
// clear db
|
||||
cy.get("#delete-org").click();
|
||||
cy.get("[data-cy='dialog-confirm']").click()
|
||||
cy.get("[data-cy='dialog-confirm']").click();
|
||||
cy.contains("successfully deleted");
|
||||
cy.url().should("eq", "http://localhost:4200/");
|
||||
cy.contains("In order to use GlitchTip, you'll need to create an");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import { organization } from "../fixtures/variables";
|
||||
import { seededOrg } from "../fixtures/variables";
|
||||
|
||||
describe("Organizations", () => {
|
||||
beforeEach(() => {
|
||||
@@ -8,33 +8,20 @@ describe("Organizations", () => {
|
||||
});
|
||||
|
||||
it("should create an org and more", () => {
|
||||
const secondOrg = "cypress second org";
|
||||
const newOrg = "e2etestobj-second-org";
|
||||
cy.visit(`/`);
|
||||
cy.get("#org-dropdown").click();
|
||||
cy.get("#create-new-link").click();
|
||||
cy.get("#create-organization-form input").type(secondOrg);
|
||||
cy.get("#create-organization-form").submit();
|
||||
// cy.get(".mat-list-item-content").contains(secondOrg);
|
||||
cy.get("[data-cy=orgSelect]").click();
|
||||
cy.get("[data-cy=createNewLink]").click();
|
||||
cy.url().should("eq", "http://localhost:4200/organizations/new");
|
||||
cy.get("[data-cy=create-organization-input]").type(newOrg);
|
||||
cy.get("[data-cy=create-organization-form]").submit();
|
||||
cy.get("[data-cy=orgSelect]").contains(newOrg);
|
||||
|
||||
// glitchtip-frontend#55: org slug in URL should always match active org.
|
||||
// It got out of sync with a back button.
|
||||
|
||||
// cy.visit(`/settings/${organization.slug}/`);
|
||||
// cy.get("#org-dropdown").click();
|
||||
// cy.get(".mat-menu-content button:nth-child(1)").click();
|
||||
// cy.go("back");
|
||||
// cy.get("#org-dropdown span").contains(organization.slug);
|
||||
});
|
||||
|
||||
it("should delete an org", () => {
|
||||
const doomedOrg = "cypress doomed org";
|
||||
cy.visit(`/organizations/new`);
|
||||
cy.get("#create-organization-form input").type(doomedOrg);
|
||||
cy.get("#create-organization-form").submit();
|
||||
cy.get("[data-test-settings]").click();
|
||||
// cy.get("[data-test] button").click();
|
||||
// cy.get(".cdk-overlay-container").contains(
|
||||
// `You have successfully deleted ${doomedOrg}`
|
||||
// );
|
||||
// Ensure url and displayed org in select component are in sync
|
||||
cy.visit(`/${seededOrg.slug}/settings`);
|
||||
cy.get("[data-cy=orgSelect]").contains(seededOrg.name).click();
|
||||
cy.get("mat-option").contains(newOrg).click();
|
||||
cy.url().should("eq", `http://localhost:4200/${newOrg}/settings`);
|
||||
cy.get("[data-cy=orgSelect]").should("contain", newOrg);
|
||||
});
|
||||
});
|
||||
+22
-22
@@ -1,9 +1,9 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import {
|
||||
organization,
|
||||
newTeam,
|
||||
seededOrg,
|
||||
secondSeededTeam,
|
||||
newProject,
|
||||
project,
|
||||
seededProject1,
|
||||
} from "../fixtures/variables";
|
||||
|
||||
describe("Create New Project", () => {
|
||||
@@ -13,28 +13,28 @@ describe("Create New Project", () => {
|
||||
});
|
||||
|
||||
it("should render appropriate field and server side errors", () => {
|
||||
cy.visit(`/${organization.slug}/settings/projects/new`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/projects/new`);
|
||||
cy.contains("Create a New Project");
|
||||
cy.get("[data-cy=create-project-submit]").click();
|
||||
cy.get("[data-cy='project-name-error-required']");
|
||||
cy.get("input[formcontrolname=name]").type(
|
||||
"While having too many characters in a project name would be rare, this test ensures that the server error field works."
|
||||
"While having too many characters in a project name would be rare, this test ensures that the server error field works.",
|
||||
);
|
||||
cy.get("[data-cy=project-name-error-length]");
|
||||
});
|
||||
|
||||
it("create new project with new team but no platform", () => {
|
||||
cy.visit(`/${organization.slug}/settings/projects/new`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/projects/new`);
|
||||
cy.contains("Create a New Project");
|
||||
cy.get("#create-team-from-projects").click();
|
||||
cy.contains(
|
||||
"Your team slug can only consist of letters, numbers, underscores and/or hyphens."
|
||||
"Your team slug can only consist of letters, numbers, underscores and/or hyphens.",
|
||||
);
|
||||
cy.get("input[formcontrolname=slug]").type(newTeam.slug);
|
||||
cy.get("input[formcontrolname=slug]").type(secondSeededTeam.slug);
|
||||
cy.get("#create-team-submit").click();
|
||||
cy.contains(new RegExp("^" + newTeam.slug + "$", "g"));
|
||||
cy.intercept("POST", "api/0/teams/business-company-inc/*/projects/").as(
|
||||
"createProjectRequest"
|
||||
cy.contains(new RegExp("^" + secondSeededTeam.slug + "$", "g"));
|
||||
cy.intercept("POST", `api/0/teams/${seededOrg.slug}/*/projects/`).as(
|
||||
"createProjectRequest",
|
||||
);
|
||||
cy.get("input[formcontrolname=name]").type(newProject.name);
|
||||
cy.get("[data-cy=create-project-form]").submit();
|
||||
@@ -43,13 +43,13 @@ describe("Create New Project", () => {
|
||||
.url()
|
||||
.should(
|
||||
"contain",
|
||||
`/${organization.slug}/issues?project=${response?.body.id}`
|
||||
)
|
||||
`/${seededOrg.slug}/issues?project=${response?.body.id}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("create new project with platform and existing team", () => {
|
||||
cy.visit(`/${organization.slug}/settings/projects/new`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/projects/new`);
|
||||
cy.contains("Create a New Project");
|
||||
cy.get("input[formcontrolname=name]").type(newProject.name);
|
||||
cy.get("[formcontrolname=platform] [data-test]").first().click();
|
||||
@@ -61,14 +61,14 @@ describe("Edit and Delete a project", () => {
|
||||
beforeEach(() => {
|
||||
seedBackend();
|
||||
requestLogin();
|
||||
cy.visit(`/${organization.slug}/settings/projects/${project.slug}`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/projects/${seededProject1.slug}`);
|
||||
});
|
||||
|
||||
it("should edit the name of a project", () => {
|
||||
cy.get("input[formcontrolname=name]")
|
||||
.clear()
|
||||
.type(
|
||||
"While having too many characters in a project name would be rare, this test ensures that the server error field works."
|
||||
"While having too many characters in a project name would be rare, this test ensures that the server error field works.",
|
||||
);
|
||||
cy.get("#update-project-name").click();
|
||||
cy.get("[data-cy='project-name-error-length']");
|
||||
@@ -83,8 +83,8 @@ describe("Edit and Delete a project", () => {
|
||||
});
|
||||
|
||||
it("edit the project platform", () => {
|
||||
cy.get("[data-test-panel-header]").click();
|
||||
cy.get("[data-test]").last().click();
|
||||
cy.get("[data-cy='platform-input']").type("WSGI");
|
||||
cy.get("[data-cy='platform-option']").last().click();
|
||||
cy.get("#update-project-platform").click();
|
||||
cy.contains("Your project platform has been updated to WSGI");
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe("Edit and Delete a project", () => {
|
||||
cy.contains("Your project has been sucessfully deleted");
|
||||
cy.url().should(
|
||||
"eq",
|
||||
`http://localhost:4200/${organization.slug}/settings/projects`
|
||||
`http://localhost:4200/${seededOrg.slug}/settings/projects`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -103,7 +103,7 @@ describe("Add and edit alerts", () => {
|
||||
beforeEach(() => {
|
||||
seedBackend(true);
|
||||
requestLogin();
|
||||
cy.visit(`/${organization.slug}/settings/projects/${project.slug}`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/projects/${seededProject1.slug}`);
|
||||
});
|
||||
|
||||
it("should add a project alert, remove email recipient, add email recipient back", () => {
|
||||
@@ -131,7 +131,7 @@ describe("Add and edit alerts", () => {
|
||||
cy.get("#create-new-alert").click();
|
||||
cy.get("[data-cy=error-check]").click();
|
||||
cy.contains(
|
||||
"Please select events or uptime monitor triggers for your alert."
|
||||
"Please select events or uptime monitor triggers for your alert.",
|
||||
);
|
||||
cy.get("[data-cy=uptime-check]").click();
|
||||
cy.get("button").contains("submit").click();
|
||||
@@ -141,7 +141,7 @@ describe("Add and edit alerts", () => {
|
||||
cy.get("[data-cy=uptime-check]").find("input").should("be.checked");
|
||||
|
||||
cy.get("[data-cy=error-check]").click();
|
||||
cy.get("[data-cy=update-container]").contains("Update").click();
|
||||
cy.get("[data-cy=update-button]").contains("Update").click();
|
||||
cy.contains("Success: Your alert has been updated");
|
||||
|
||||
cy.get("[data-cy=error-check]").find("input").should("be.checked");
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { seedBackend, requestLogin, getDSN } from "./utils.cy";
|
||||
import * as jsErrors from "../fixtures/events";
|
||||
import * as djangoErrors from "../fixtures/django-events";
|
||||
import { organization, project2, project3 } from "../fixtures/variables";
|
||||
|
||||
function seedJavaScriptIssues(dsn: string) {
|
||||
const url = getDSN(dsn);
|
||||
cy.request("POST", url, jsErrors.evalError);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.autoplayError);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.jsRangeError);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.debugMessage);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.jsReferenceError);
|
||||
cy.wait(1000);
|
||||
jsErrors.generateErrors(url, 10);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.infoMessage);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.criticalMessage);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.jsSyntaxError);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, jsErrors.jsUriError);
|
||||
cy.wait(1000);
|
||||
}
|
||||
|
||||
function seedDjangoIssues(dsn: string) {
|
||||
const url = getDSN(dsn);
|
||||
cy.request("POST", url, djangoErrors.noReverseMatch);
|
||||
cy.wait(1000);
|
||||
cy.request("POST", url, djangoErrors.logging);
|
||||
}
|
||||
|
||||
describe("Screenshot Run", () => {
|
||||
if (Cypress.env("screenshot")) {
|
||||
beforeEach(() => {
|
||||
seedBackend(true);
|
||||
requestLogin();
|
||||
cy.visit(`/${organization.slug}/issues`);
|
||||
cy.get("gt-project-filter-bar mat-expansion-panel-header").click();
|
||||
cy.get("gt-project-filter-bar").contains(project3.name).click();
|
||||
// Need the DSN to do this from the frontend
|
||||
cy.get("[data-test-dsn]")
|
||||
.invoke("val")
|
||||
.then((dsn) => seedJavaScriptIssues(dsn as string));
|
||||
|
||||
cy.get("gt-project-filter-bar mat-expansion-panel-header").click();
|
||||
cy.get("gt-project-filter-bar").contains(project2.name).click();
|
||||
// Need the DSN to do this from the frontend
|
||||
cy.get("[data-test-dsn]")
|
||||
.invoke("val")
|
||||
.then((dsn) => seedDjangoIssues(dsn as string));
|
||||
});
|
||||
|
||||
it("should show issues, recognize environments", () => {
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit(`/${organization.slug}/issues`);
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
// cy.viewport(1285, 800);
|
||||
cy.viewport(2570, 1600);
|
||||
cy.screenshot("issues-page");
|
||||
|
||||
cy.get(".title-cell").find("a").contains("<unknown>").click();
|
||||
cy.wait(1000);
|
||||
cy.screenshot("issue-detail");
|
||||
});
|
||||
}
|
||||
});
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import { organization, newTeam, team } from "../fixtures/variables";
|
||||
import { user } from "../fixtures/users";
|
||||
import { seededOrg, secondSeededTeam, seededTeam } from "../fixtures/variables";
|
||||
import { seededUser1 } from "../fixtures/users";
|
||||
|
||||
describe("Create New Team", () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,18 +9,18 @@ describe("Create New Team", () => {
|
||||
});
|
||||
|
||||
it("should add and update teams", () => {
|
||||
cy.visit(`/${organization.slug}/settings/teams`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/teams`);
|
||||
cy.get("#new-team").click();
|
||||
cy.get("input[formcontrolname=slug]").type(newTeam.slug);
|
||||
cy.get("input[formcontrolname=slug]").type(secondSeededTeam.slug);
|
||||
cy.get("#create-team-submit").click();
|
||||
cy.contains(`#${newTeam.slug}`);
|
||||
cy.contains(`#${secondSeededTeam.slug}`);
|
||||
});
|
||||
|
||||
it("should show validation errors", () => {
|
||||
cy.visit(`/${organization.slug}/settings/teams`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/teams`);
|
||||
cy.get("#new-team").click();
|
||||
cy.get("input[formcontrolname=slug]").type(
|
||||
newTeam.slug + " invalid ch@r@cter$",
|
||||
secondSeededTeam.slug + " invalid ch@r@cter$",
|
||||
);
|
||||
cy.get("#create-team-submit").click();
|
||||
cy.contains("Use only letters, numbers, underscores");
|
||||
@@ -34,24 +34,24 @@ describe("List Team Members", () => {
|
||||
});
|
||||
|
||||
it("should add and list team member", () => {
|
||||
cy.visit(`/${organization.slug}/settings/teams/${team.name}/members/`);
|
||||
cy.contains(`#${team.name}`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/teams/${seededTeam.name}/members/`);
|
||||
cy.contains(`#${seededTeam.name}`);
|
||||
cy.get("[data-cy=team-member-select]")
|
||||
.click()
|
||||
.get("mat-select")
|
||||
.get("mat-option")
|
||||
.contains(user.email)
|
||||
.contains(seededUser1.email)
|
||||
.click();
|
||||
cy.get("[data-test-list] li").first().contains(user.email);
|
||||
cy.get("[data-test-list] li").first().contains(seededUser1.email);
|
||||
});
|
||||
|
||||
it("should remove a team member", () => {
|
||||
cy.visit(`/${organization.slug}/settings/teams/${team.name}/members/`);
|
||||
cy.visit(`/${seededOrg.slug}/settings/teams/${seededTeam.name}/members/`);
|
||||
cy.get("[data-cy=team-member-select]")
|
||||
.click()
|
||||
.get("mat-select")
|
||||
.get("mat-option")
|
||||
.contains(user.email)
|
||||
.contains(seededUser1.email)
|
||||
.click();
|
||||
cy.get("#remove-team-member").click();
|
||||
cy.contains("This team doesn't have any members");
|
||||
|
||||
+16
-16
@@ -1,5 +1,5 @@
|
||||
import { seedBackend, requestLogin } from "./utils.cy";
|
||||
import { organization } from "../fixtures/variables";
|
||||
import { seededOrg, seededProject1, seededMonitorName } from "../fixtures/variables";
|
||||
|
||||
describe("List, add, update and delete uptime Monitors", () => {
|
||||
beforeEach(() => {
|
||||
@@ -8,35 +8,35 @@ describe("List, add, update and delete uptime Monitors", () => {
|
||||
});
|
||||
|
||||
it("Should list a single monitor, see alert info for that monitor, then update that monitor and see updated monitor on list", () => {
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/`);
|
||||
cy.contains("cytestmonitor").click();
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/`);
|
||||
cy.contains("seeded-monitor").click();
|
||||
cy.wait(1000);
|
||||
cy.contains("Uptime details for cytestmonitor");
|
||||
cy.contains(`Uptime details for ${seededMonitorName}`);
|
||||
cy.contains("This project has no uptime alerts configured");
|
||||
cy.get("#monitor-settings").click();
|
||||
cy.get("#monitor-name")
|
||||
.should("have.value", "cytestmonitor")
|
||||
.should("have.value", seededMonitorName)
|
||||
.clear()
|
||||
.type("new name");
|
||||
cy.get("button").contains("Update Monitor").click();
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/`);
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/`);
|
||||
cy.contains("new name");
|
||||
});
|
||||
|
||||
it("should delete a monitor and not see that monitor on list", () => {
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/`);
|
||||
cy.contains("cytestmonitor").click();
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/`);
|
||||
cy.contains(seededMonitorName).click();
|
||||
cy.get("#monitor-settings").click();
|
||||
cy.on("window:confirm", (text) => {
|
||||
expect(text).to.contains("Are you sure you want delete this monitor?");
|
||||
});
|
||||
cy.get("#delete-monitor").click();
|
||||
cy.visit(`/${organization.slug}/uptime-monitors`);
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors`);
|
||||
cy.contains("cytestmonitor").should("not.exist");
|
||||
});
|
||||
|
||||
it("Should not be able to add monitor with invalid values", () => {
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/new`);
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/new`);
|
||||
cy.get("[data-cy=monitor-type]")
|
||||
.click()
|
||||
.get("mat-option")
|
||||
@@ -53,15 +53,15 @@ describe("List, add, update and delete uptime Monitors", () => {
|
||||
});
|
||||
|
||||
it("Should add a single monitor and see that monitor on list", () => {
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/`);
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/`);
|
||||
cy.get("#add-monitor").click();
|
||||
cy.get("#monitor-name").type("secondmonitor");
|
||||
cy.get("#monitor-name").type("second-monitor");
|
||||
cy.get("[data-cy=site-url]").type("www.twitter.com");
|
||||
cy.get("[data-cy=associated-project]")
|
||||
.click()
|
||||
.get("mat-select")
|
||||
.get("mat-option")
|
||||
.contains("NicheScrip")
|
||||
.contains(seededProject1.name)
|
||||
.click();
|
||||
cy.get("[data-cy=monitor-type]")
|
||||
.click()
|
||||
@@ -70,8 +70,8 @@ describe("List, add, update and delete uptime Monitors", () => {
|
||||
.click();
|
||||
cy.get("[data-cy=interval]").clear().type("605");
|
||||
cy.get("[data-cy=monitor-submit]").click();
|
||||
cy.contains("Uptime details for secondmonitor");
|
||||
cy.visit(`/${organization.slug}/uptime-monitors/`);
|
||||
cy.contains("secondmonitor");
|
||||
cy.contains("Uptime details for second-monitor");
|
||||
cy.visit(`/${seededOrg.slug}/uptime-monitors/`);
|
||||
cy.contains("second-monitor");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { user } from "../fixtures/users";
|
||||
import { seededUser1 } from "../fixtures/users";
|
||||
|
||||
export function seedBackend(generateExtras = false, seedIssues = false) {
|
||||
let paramString = generateExtras ? "?extras=true" : ""
|
||||
@@ -24,8 +24,8 @@ export function requestLogin() {
|
||||
method: "POST",
|
||||
url,
|
||||
body: {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
email: seededUser1.email,
|
||||
password: seededUser1.password,
|
||||
},
|
||||
headers: { "X-CSRFToken": csrfToken },
|
||||
});
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
export const noReverseMatch = {
|
||||
level: "error",
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
module: "django.urls.exceptions",
|
||||
type: "NoReverseMatch",
|
||||
value:
|
||||
"Reverse for 'nope' not found. 'nope' is not a valid view function or pattern name.",
|
||||
mechanism: {
|
||||
type: "django",
|
||||
handled: false,
|
||||
},
|
||||
stacktrace: {
|
||||
frames: [
|
||||
{
|
||||
filename: "django/core/handlers/exception.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/core/handlers/exception.py",
|
||||
function: "inner",
|
||||
module: "django.core.handlers.exception",
|
||||
lineno: 34,
|
||||
pre_context: [
|
||||
" can rely on getting a response instead of an exception.",
|
||||
' """',
|
||||
" @wraps(get_response)",
|
||||
" def inner(request):",
|
||||
" try:",
|
||||
],
|
||||
context_line: " response = get_response(request)",
|
||||
post_context: [
|
||||
" except Exception as exc:",
|
||||
" response = response_for_exception(request, exc)",
|
||||
" return response",
|
||||
" return inner",
|
||||
"",
|
||||
],
|
||||
vars: {
|
||||
request: "<WSGIRequest: GET '/template-error/'>",
|
||||
exc: "NoReverseMatch(\"Reverse for 'nope' not found. 'nope' is not a valid view function or pattern name.\")",
|
||||
get_response:
|
||||
"<bound method BaseHandler._get_response of <django.core.handlers.wsgi.WSGIHandler object at 0x7fc14cb6efa0>>",
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "django/core/handlers/base.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/core/handlers/base.py",
|
||||
function: "_get_response",
|
||||
module: "django.core.handlers.base",
|
||||
lineno: 145,
|
||||
pre_context: [
|
||||
" )",
|
||||
"",
|
||||
" try:",
|
||||
" response = response.render()",
|
||||
" except Exception as e:",
|
||||
],
|
||||
context_line:
|
||||
" response = self.process_exception_by_middleware(e, request)",
|
||||
post_context: [
|
||||
"",
|
||||
" return response",
|
||||
"",
|
||||
" def process_exception_by_middleware(self, exception, request):",
|
||||
' """',
|
||||
],
|
||||
vars: {
|
||||
self: "<django.core.handlers.wsgi.WSGIHandler object at 0x7fc14cb6efa0>",
|
||||
request: "<WSGIRequest: GET '/template-error/'>",
|
||||
response:
|
||||
'<TemplateResponse status_code=200, "text/html; charset=utf-8">',
|
||||
resolver:
|
||||
"<URLResolver 'django_error_factory.urls' (None:None) '^/'>",
|
||||
resolver_match:
|
||||
"ResolverMatch(func=errors.views.TemplateErrorView, args=(), kwargs={}, url_name=template_error, app_names=[], namespaces=[], route=template-error/)",
|
||||
callback: "<function TemplateErrorView at 0x7fc14c63f940>",
|
||||
callback_args: [],
|
||||
callback_kwargs: {},
|
||||
middleware_method:
|
||||
"<function CsrfViewMiddleware.process_view at 0x7fc14bc8ce50>",
|
||||
wrapped_callback:
|
||||
"<function TemplateErrorView at 0x7fc14c63f940>",
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "django/core/handlers/base.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/core/handlers/base.py",
|
||||
function: "_get_response",
|
||||
module: "django.core.handlers.base",
|
||||
lineno: 143,
|
||||
pre_context: [
|
||||
' "HttpResponse object. It returned None instead."',
|
||||
" % (middleware_method.__self__.__class__.__name__)",
|
||||
" )",
|
||||
"",
|
||||
" try:",
|
||||
],
|
||||
context_line: " response = response.render()",
|
||||
post_context: [
|
||||
" except Exception as e:",
|
||||
" response = self.process_exception_by_middleware(e, request)",
|
||||
"",
|
||||
" return response",
|
||||
"",
|
||||
],
|
||||
vars: {
|
||||
self: "<django.core.handlers.wsgi.WSGIHandler object at 0x7fc14cb6efa0>",
|
||||
request: "<WSGIRequest: GET '/template-error/'>",
|
||||
response:
|
||||
'<TemplateResponse status_code=200, "text/html; charset=utf-8">',
|
||||
resolver:
|
||||
"<URLResolver 'django_error_factory.urls' (None:None) '^/'>",
|
||||
resolver_match:
|
||||
"ResolverMatch(func=errors.views.TemplateErrorView, args=(), kwargs={}, url_name=template_error, app_names=[], namespaces=[], route=template-error/)",
|
||||
callback: "<function TemplateErrorView at 0x7fc14c63f940>",
|
||||
callback_args: [],
|
||||
callback_kwargs: {},
|
||||
middleware_method:
|
||||
"<function CsrfViewMiddleware.process_view at 0x7fc14bc8ce50>",
|
||||
wrapped_callback:
|
||||
"<function TemplateErrorView at 0x7fc14c63f940>",
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "django/template/response.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/template/response.py",
|
||||
function: "render",
|
||||
module: "django.template.response",
|
||||
lineno: 105,
|
||||
pre_context: [
|
||||
"",
|
||||
" Return the baked response instance.",
|
||||
' """',
|
||||
" retval = self",
|
||||
" if not self._is_rendered:",
|
||||
],
|
||||
context_line: " self.content = self.rendered_content",
|
||||
post_context: [
|
||||
" for post_callback in self._post_render_callbacks:",
|
||||
" newretval = post_callback(retval)",
|
||||
" if newretval is not None:",
|
||||
" retval = newretval",
|
||||
" return retval",
|
||||
],
|
||||
vars: {
|
||||
self: '<TemplateResponse status_code=200, "text/html; charset=utf-8">',
|
||||
retval:
|
||||
'<TemplateResponse status_code=200, "text/html; charset=utf-8">',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "django/urls/base.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/urls/base.py",
|
||||
function: "reverse",
|
||||
module: "django.urls.base",
|
||||
lineno: 87,
|
||||
pre_context: [
|
||||
" else:",
|
||||
' raise NoReverseMatch("%s is not a registered namespace" % key)',
|
||||
" if ns_pattern:",
|
||||
" resolver = get_ns_resolver(ns_pattern, resolver, tuple(ns_converters.items()))",
|
||||
"",
|
||||
],
|
||||
context_line:
|
||||
" return iri_to_uri(resolver._reverse_with_prefix(view, prefix, *args, **kwargs))",
|
||||
post_context: [
|
||||
"",
|
||||
"",
|
||||
"reverse_lazy = lazy(reverse, str)",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
vars: {
|
||||
viewname: "'nope'",
|
||||
urlconf: "'django_error_factory.urls'",
|
||||
args: [],
|
||||
kwargs: {},
|
||||
current_app: "''",
|
||||
resolver:
|
||||
"<URLResolver 'django_error_factory.urls' (None:None) '^/'>",
|
||||
prefix: "'/'",
|
||||
view: "'nope'",
|
||||
path: [],
|
||||
current_path: "None",
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "django/urls/resolvers.py",
|
||||
abs_path:
|
||||
"/usr/local/lib/python3.8/site-packages/django/urls/resolvers.py",
|
||||
function: "_reverse_with_prefix",
|
||||
module: "django.urls.resolvers",
|
||||
lineno: 677,
|
||||
pre_context: [
|
||||
" else:",
|
||||
" msg = (",
|
||||
" \"Reverse for '%(view)s' not found. '%(view)s' is not \"",
|
||||
" \"a valid view function or pattern name.\" % {'view': lookup_view_s}",
|
||||
" )",
|
||||
],
|
||||
context_line: " raise NoReverseMatch(msg)",
|
||||
post_context: [],
|
||||
vars: {
|
||||
self: "<URLResolver 'django_error_factory.urls' (None:None) '^/'>",
|
||||
lookup_view: "'nope'",
|
||||
_prefix: "'/'",
|
||||
args: [],
|
||||
possibilities: [],
|
||||
m: "None",
|
||||
n: "None",
|
||||
lookup_view_s: "'nope'",
|
||||
patterns: [],
|
||||
msg: "\"Reverse for 'nope' not found. 'nope' is not a valid view function or pattern name.\"",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
event_id: "3e7c1345914d4924926d795ade8240cb",
|
||||
timestamp: "2020-05-13T00:37:49.130239Z",
|
||||
breadcrumbs: [],
|
||||
transaction: "/template-error/",
|
||||
contexts: {
|
||||
trace: {
|
||||
trace_id: "7f6b76eac5d042b78752db640a848008",
|
||||
span_id: "802ffce6fe075f40",
|
||||
parent_span_id: "be0f515132150c50",
|
||||
op: "django.middleware",
|
||||
description:
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware.__call__",
|
||||
},
|
||||
runtime: {
|
||||
name: "CPython",
|
||||
version: "3.8.2",
|
||||
build: "3.8.2 (default, Apr 23 2020, 14:32:57) \n[GCC 8.3.0]",
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
wheel: "0.34.2",
|
||||
webencodings: "0.5.1",
|
||||
attrs: "19.3.0",
|
||||
asgiref: "3.2.7",
|
||||
appdirs: "1.4.3",
|
||||
},
|
||||
extra: {
|
||||
"sys.argv": ["./manage.py", "runserver", "0.0.0.0:8001"],
|
||||
},
|
||||
request: {
|
||||
url: "http://localhost:8001/template-error/",
|
||||
query_string: "",
|
||||
method: "GET",
|
||||
env: {
|
||||
SERVER_NAME: "4c55e9e8d666",
|
||||
SERVER_PORT: "8001",
|
||||
},
|
||||
headers: {
|
||||
"Content-Length": "",
|
||||
"Content-Type": "text/plain",
|
||||
Host: "localhost:8001",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:76.0) Gecko/20100101 Firefox/76.0",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
Connection: "keep-alive",
|
||||
Referer: "http://localhost:8001/",
|
||||
Cookie: "",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
Dnt: "1",
|
||||
},
|
||||
},
|
||||
server_name: "4c55e9e8d666",
|
||||
sdk: {
|
||||
name: "sentry.python",
|
||||
version: "0.14.3",
|
||||
packages: [
|
||||
{
|
||||
name: "pypi:sentry-sdk",
|
||||
version: "0.14.3",
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
"argv",
|
||||
"atexit",
|
||||
"dedupe",
|
||||
"django",
|
||||
"excepthook",
|
||||
"logging",
|
||||
"modules",
|
||||
"stdlib",
|
||||
"threading",
|
||||
],
|
||||
},
|
||||
platform: "python",
|
||||
_meta: {
|
||||
exception: {
|
||||
values: {
|
||||
"0": {
|
||||
stacktrace: {
|
||||
frames: {
|
||||
"12": {
|
||||
vars: {
|
||||
"": {
|
||||
len: 13,
|
||||
},
|
||||
},
|
||||
},
|
||||
"13": {
|
||||
vars: {
|
||||
"": {
|
||||
len: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
request: {
|
||||
headers: {
|
||||
Cookie: {
|
||||
"": {
|
||||
rem: [["!config", "x", 0, 373]],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const logging = {
|
||||
level: "warning",
|
||||
logger: "errors.views",
|
||||
logentry: { message: "Server warning: better have a look", params: [] },
|
||||
extra: { "sys.argv": ["./manage.py", "runserver", "0.0.0.0:8001"] },
|
||||
event_id: "78d24ed7495c46fc8dbde72c1f20679b",
|
||||
timestamp: "2020-04-24T18:23:02.950832Z",
|
||||
breadcrumbs: [],
|
||||
transaction: "/warning/",
|
||||
contexts: {
|
||||
trace: {
|
||||
trace_id: "1022e5e9d8e44d2ebafddfa839b58c5f",
|
||||
span_id: "9d329137b651e5ce",
|
||||
parent_span_id: "b18a82b300d6699d",
|
||||
op: "django.middleware",
|
||||
description:
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware.__call__",
|
||||
},
|
||||
runtime: {
|
||||
name: "CPython",
|
||||
version: "3.8.2",
|
||||
build: "3.8.2 (default, Feb 26 2020, 15:09:34) \n[GCC 8.3.0]",
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
wheel: "0.34.2",
|
||||
webencodings: "0.5.1",
|
||||
wcwidth: "0.1.8",
|
||||
urllib3: "1.25.8",
|
||||
"typed-ast": "1.4.1",
|
||||
traitlets: "4.3.3",
|
||||
tomlkit: "0.5.11",
|
||||
toml: "0.10.0",
|
||||
sqlparse: "0.3.1",
|
||||
six: "1.14.0",
|
||||
appdirs: "1.4.3",
|
||||
},
|
||||
request: {
|
||||
url: "http://localhost:8001/warning/",
|
||||
query_string: "",
|
||||
method: "GET",
|
||||
env: { SERVER_NAME: "5949a3cc0cd4", SERVER_PORT: "8001" },
|
||||
headers: {
|
||||
"Content-Length": "",
|
||||
"Content-Type": "text/plain",
|
||||
Host: "localhost:8001",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
Referer: "http://localhost:8001/",
|
||||
Connection: "keep-alive",
|
||||
Cookie: "",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Cache-Control": "max-age=0",
|
||||
},
|
||||
},
|
||||
server_name: "5949a3cc0cd4",
|
||||
sdk: {
|
||||
name: "sentry.python",
|
||||
version: "0.14.2",
|
||||
packages: [{ name: "pypi:sentry-sdk", version: "0.14.2" }],
|
||||
integrations: [
|
||||
"argv",
|
||||
"atexit",
|
||||
"dedupe",
|
||||
"django",
|
||||
"excepthook",
|
||||
"logging",
|
||||
"modules",
|
||||
"stdlib",
|
||||
"threading",
|
||||
],
|
||||
},
|
||||
platform: "python",
|
||||
_meta: {
|
||||
request: {
|
||||
headers: { Cookie: { "": { rem: [["!config", "x", 0, 1110]] } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,10 @@
|
||||
export const user = {
|
||||
email: "cypresstest@example.com",
|
||||
export const seededUser1 = {
|
||||
email: "seeded-user@example.com",
|
||||
password: "hunter22",
|
||||
};
|
||||
|
||||
export const seededUser2 = {
|
||||
email: "second-seeded-user@example.com",
|
||||
password: "hunter22",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
// import { sorts as appSorts } from "../../src/app/issues/issues-page/issues-page.component";
|
||||
|
||||
export const organization = {
|
||||
name: "Business Company, Inc.",
|
||||
slug: "business-company-inc",
|
||||
otherOrg: "cypress-test-org-other",
|
||||
export const seededOrg = {
|
||||
name: "e2etestobj-seeded-org",
|
||||
slug: "e2etestobj-seeded-org",
|
||||
};
|
||||
|
||||
export const team = {
|
||||
name: "cypresstestteam",
|
||||
export const seededTeam = {
|
||||
name: "seeded-team",
|
||||
};
|
||||
|
||||
export const newTeam = {
|
||||
slug: "newcypresstestteam",
|
||||
export const secondSeededTeam = {
|
||||
slug: "second-seeded-team",
|
||||
};
|
||||
|
||||
export const project = {
|
||||
name: "NicheScrip",
|
||||
slug: "nichescrip",
|
||||
export const seededProject1 = {
|
||||
name: "seeded-project",
|
||||
slug: "seeded-project",
|
||||
};
|
||||
|
||||
export const project2 = {
|
||||
name: "SwitchGrip",
|
||||
slug: "switchgrip",
|
||||
export const seededProject2 = {
|
||||
name: "second-seeded-project",
|
||||
slug: "second-seeded-project",
|
||||
};
|
||||
|
||||
export const project3 = {
|
||||
name: "PitchFlip",
|
||||
slug: "pitchflip",
|
||||
export const seededProject3 = {
|
||||
name: "third-seeded-project",
|
||||
slug: "third-seeded-project",
|
||||
};
|
||||
|
||||
export const seededMonitorName = "seeded-monitor"
|
||||
|
||||
export const newProject = {
|
||||
name: "newcypresstestproject",
|
||||
name: "other-project",
|
||||
platform: "newcypresstestplatform",
|
||||
};
|
||||
|
||||
|
||||
Generated
+982
-1495
File diff suppressed because it is too large.
Load diff
+6
-6
@@ -14,8 +14,6 @@
|
||||
"postinstall": "patch-package",
|
||||
"lint": "ng lint",
|
||||
"cy:open": "cypress open",
|
||||
"screenshot": "SCREENSHOT=true cypress run --spec cypress/integration/screenshot-run.ts --env screenshot=true",
|
||||
"cy:openscreenshot": "cypress open --env screenshot=true",
|
||||
"cy:run": "cypress run",
|
||||
"e2e": "concurrently \"npm run start -- --no-hmr\" \"wait-on http-get://localhost:4200 && npm run cy:run\" --kill-others --success first",
|
||||
"build-stats": "ng build -c production --stats-json",
|
||||
@@ -57,6 +55,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^4.18.2",
|
||||
"ngx-markdown": "^20.0.0",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -79,8 +78,8 @@
|
||||
"@babel/core": "^7.7.2",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@cypress/webpack-preprocessor": "^6.0.2",
|
||||
"@storybook/addon-docs": "~9.0.18",
|
||||
"@storybook/angular": "~9.0.18",
|
||||
"@storybook/addon-docs": "~9.1.1",
|
||||
"@storybook/angular": "~9.1.1",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -96,7 +95,7 @@
|
||||
"cypress": "^14.5.1",
|
||||
"cypress-localstorage-commands": "^2.2.7",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-storybook": "^9.0.18",
|
||||
"eslint-plugin-storybook": "^9.1.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"glob": "^11.0.1",
|
||||
"jasmine-core": "^5.0.0",
|
||||
@@ -116,5 +115,6 @@
|
||||
"typescript": "^5.9.2",
|
||||
"wait-on": "^5.3.0",
|
||||
"xliff": "^6.2.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, ViewEncapsulation } from "@angular/core";
|
||||
import { MatCard, MatCardContent } from "@angular/material/card";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { platforms } from "src/app/settings/projects/platform-picker/platforms";
|
||||
import { platforms } from "src/app/settings/projects/new-project/platform-picker/platforms";
|
||||
|
||||
// const SDK_DOCS_ROUTE = "/sdkdocs/";
|
||||
// const sdkBlacklist = [
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { AfterViewChecked, Component, OnInit, ViewEncapsulation, inject } from "@angular/core";
|
||||
import {
|
||||
AfterViewChecked,
|
||||
Component,
|
||||
OnInit,
|
||||
ViewEncapsulation,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { HighlightService } from "../shared/highlight.service";
|
||||
import { MarkdownComponent } from "ngx-markdown";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { MatCard, MatCardContent } from "@angular/material/card";
|
||||
import { flattenedPlatforms } from "src/app/settings/projects/platform-picker/platforms-for-picker";
|
||||
import { flattenedPlatforms } from "src/app/settings/projects/new-project/platform-picker/platforms-for-picker";
|
||||
|
||||
@Component({
|
||||
imports: [MarkdownComponent, MatCard, MatCardContent],
|
||||
|
||||
Vendored
+80
-33
@@ -1724,6 +1724,8 @@ export interface components {
|
||||
iPaidForGlitchTip: boolean;
|
||||
/** Enableuserregistration */
|
||||
enableUserRegistration: boolean;
|
||||
/** Enablesocialappsuserregistration */
|
||||
enableSocialAppsUserRegistration: boolean;
|
||||
/** Enableorganizationcreation */
|
||||
enableOrganizationCreation: boolean;
|
||||
/** Stripepublickey */
|
||||
@@ -2089,19 +2091,6 @@ export interface components {
|
||||
/** Timestamp */
|
||||
timestamp?: string | null;
|
||||
};
|
||||
/** EventException */
|
||||
EventException: {
|
||||
/** Type */
|
||||
type?: string | null;
|
||||
/** Value */
|
||||
value?: string | null;
|
||||
/** Module */
|
||||
module?: string | null;
|
||||
/** Thread Id */
|
||||
thread_id?: string | null;
|
||||
mechanism?: components["schemas"]["ExceptionMechanism"] | null;
|
||||
stacktrace?: components["schemas"]["StackTrace"] | null;
|
||||
};
|
||||
/** EventGeo */
|
||||
EventGeo: {
|
||||
/** City */
|
||||
@@ -2117,16 +2106,17 @@ export interface components {
|
||||
EventIngestSchema: {
|
||||
/** Platform */
|
||||
platform?: string | null;
|
||||
/** Errors */
|
||||
errors?: unknown[] | null;
|
||||
/**
|
||||
* Event Id
|
||||
* Format: uuid
|
||||
*/
|
||||
event_id: string;
|
||||
/**
|
||||
* Timestamp
|
||||
* Format: date-time
|
||||
*/
|
||||
timestamp?: string;
|
||||
/** Timestamp */
|
||||
timestamp?: string | {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Level
|
||||
* @default error
|
||||
@@ -2159,15 +2149,11 @@ export interface components {
|
||||
} | null;
|
||||
/** Fingerprint */
|
||||
fingerprint?: (string | null)[] | null;
|
||||
/** Errors */
|
||||
errors?: unknown[] | null;
|
||||
/** Exception */
|
||||
exception?: components["schemas"]["EventException"][] | components["schemas"]["ValueEventException"] | null;
|
||||
exception?: components["schemas"]["IngestValueEventException"] | null;
|
||||
/** Message */
|
||||
message?: string | components["schemas"]["EventMessage"] | null;
|
||||
template?: components["schemas"]["EventTemplate"] | null;
|
||||
/** Breadcrumbs */
|
||||
breadcrumbs?: components["schemas"]["EventBreadcrumb"][] | components["schemas"]["ValueEventBreadcrumb"] | null;
|
||||
breadcrumbs?: components["schemas"]["ValueEventBreadcrumb"] | null;
|
||||
sdk?: components["schemas"]["ClientSDKInfo"] | null;
|
||||
request?: components["schemas"]["IngestRequest"] | null;
|
||||
/** Contexts */
|
||||
@@ -2234,6 +2220,12 @@ export interface components {
|
||||
handled?: boolean | null;
|
||||
/** Synthetic */
|
||||
synthetic?: boolean | null;
|
||||
/** Is Exception Group */
|
||||
is_exception_group?: boolean | null;
|
||||
/** Parent Id */
|
||||
parent_id?: number | null;
|
||||
/** Source */
|
||||
source?: string | null;
|
||||
/** Meta */
|
||||
meta?: {
|
||||
[key: string]: unknown;
|
||||
@@ -2281,6 +2273,20 @@ export interface components {
|
||||
/** Supports Geometry Shaders */
|
||||
supports_geometry_shaders?: boolean | null;
|
||||
};
|
||||
/** IngestEventException */
|
||||
IngestEventException: {
|
||||
/** Type */
|
||||
type?: string | null;
|
||||
/** Value */
|
||||
value?: string | null;
|
||||
/** Module */
|
||||
module?: string | null;
|
||||
/** Thread Id */
|
||||
thread_id?: string | null;
|
||||
mechanism?: components["schemas"]["ExceptionMechanism"] | null;
|
||||
stacktrace?: components["schemas"]["StackTrace"] | null;
|
||||
raw_stacktrace?: components["schemas"]["StackTrace"] | null;
|
||||
};
|
||||
/** IngestRequest */
|
||||
IngestRequest: {
|
||||
/** Api Target */
|
||||
@@ -2318,6 +2324,24 @@ export interface components {
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
/** IngestValueEventException */
|
||||
IngestValueEventException: {
|
||||
/** Values */
|
||||
values: components["schemas"]["IngestEventException"][];
|
||||
};
|
||||
/** LockReason */
|
||||
LockReason: {
|
||||
/** Type */
|
||||
type: number;
|
||||
/** Address */
|
||||
address?: string | null;
|
||||
/** Package Name */
|
||||
package_name?: string | null;
|
||||
/** Class Name */
|
||||
class_name?: string | null;
|
||||
/** Thread Id */
|
||||
thread_id?: string | null;
|
||||
};
|
||||
/** OSContext */
|
||||
OSContext: {
|
||||
/**
|
||||
@@ -2411,6 +2435,10 @@ export interface components {
|
||||
function?: string | null;
|
||||
/** Raw Function */
|
||||
raw_function?: string | null;
|
||||
/** Function Id */
|
||||
function_id?: string | null;
|
||||
/** Symbol */
|
||||
symbol?: string | null;
|
||||
/** Module */
|
||||
module?: string | null;
|
||||
/** Lineno */
|
||||
@@ -2431,6 +2459,7 @@ export interface components {
|
||||
in_app?: boolean | null;
|
||||
/** Stack Start */
|
||||
stack_start?: boolean | null;
|
||||
lock?: components["schemas"]["LockReason"] | null;
|
||||
/** Vars */
|
||||
vars?: {
|
||||
[key: string]: string | {
|
||||
@@ -2499,11 +2528,6 @@ export interface components {
|
||||
/** Values */
|
||||
values: components["schemas"]["EventBreadcrumb"][];
|
||||
};
|
||||
/** ValueEventException */
|
||||
ValueEventException: {
|
||||
/** Values */
|
||||
values: components["schemas"]["EventException"][];
|
||||
};
|
||||
/** EnvelopeIngestOut */
|
||||
EnvelopeIngestOut: {
|
||||
/** Id */
|
||||
@@ -2584,6 +2608,8 @@ export interface components {
|
||||
* @default
|
||||
*/
|
||||
url: string | "" | null;
|
||||
/** Tagstoadd */
|
||||
tagsToAdd?: string[] | null;
|
||||
};
|
||||
/** ProjectAlertIn */
|
||||
ProjectAlertIn: {
|
||||
@@ -2614,6 +2640,8 @@ export interface components {
|
||||
* Format: uri
|
||||
*/
|
||||
url: string;
|
||||
/** Tagstoadd */
|
||||
tagsToAdd?: string[] | null;
|
||||
};
|
||||
/** AssemblePayload */
|
||||
AssemblePayload: {
|
||||
@@ -2752,6 +2780,19 @@ export interface components {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* EventProcessingError
|
||||
* @description Represents a single error encountered during event processing,
|
||||
* matching the Sentry event schema.
|
||||
*/
|
||||
EventProcessingError: {
|
||||
/** Type */
|
||||
type: string;
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** Value */
|
||||
value?: unknown | null;
|
||||
};
|
||||
/** ExceptionEntry */
|
||||
ExceptionEntry: {
|
||||
/**
|
||||
@@ -2768,6 +2809,8 @@ export interface components {
|
||||
IssueEventSchema: {
|
||||
/** Platform */
|
||||
platform?: string | null;
|
||||
/** Errors */
|
||||
errors?: components["schemas"]["EventProcessingError"][] | null;
|
||||
/** Id.Hex */
|
||||
"id.hex": string;
|
||||
/** Eventid */
|
||||
@@ -2884,6 +2927,8 @@ export interface components {
|
||||
IssueEventDetailSchema: {
|
||||
/** Platform */
|
||||
platform?: string | null;
|
||||
/** Errors */
|
||||
errors?: components["schemas"]["EventProcessingError"][] | null;
|
||||
/** Id.Hex */
|
||||
"id.hex": string;
|
||||
/** Eventid */
|
||||
@@ -2980,6 +3025,8 @@ export interface components {
|
||||
IssueEventJsonSchema: {
|
||||
/** Platform */
|
||||
platform?: string | null;
|
||||
/** Errors */
|
||||
errors?: components["schemas"]["EventProcessingError"][] | null;
|
||||
/** Id.Hex */
|
||||
"id.hex": string;
|
||||
/**
|
||||
@@ -3261,10 +3308,10 @@ export interface components {
|
||||
* @description Represents the 24-hour statistics block.
|
||||
*/
|
||||
StatsDetailSchema: {
|
||||
/** Stats 24H */
|
||||
stats_24h?: number[][] | null;
|
||||
/** Stats 14D */
|
||||
stats_14d?: number[][] | null;
|
||||
/** 24H */
|
||||
"24h"?: number[][] | null;
|
||||
/** 14D */
|
||||
"14d"?: number[][] | null;
|
||||
};
|
||||
/** IssueHashSchema */
|
||||
IssueHashSchema: {
|
||||
|
||||
@@ -23,6 +23,9 @@ export class SettingsService {
|
||||
enableUserRegistration = computed(
|
||||
() => this.settings()?.enableUserRegistration,
|
||||
);
|
||||
enableSocialAppsUserRegistration = computed(
|
||||
() => this.settings()?.enableSocialAppsUserRegistration,
|
||||
);
|
||||
enableOrganizationCreation = computed(
|
||||
() => this.settings()?.enableOrganizationCreation,
|
||||
);
|
||||
|
||||
@@ -12,12 +12,14 @@ interface UserState {
|
||||
userDeleteError: string | null;
|
||||
userDeleteLoading: boolean;
|
||||
disconnectLoading: number | null;
|
||||
throttleWarningDismissedOrgs: string[];
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
userDeleteError: null,
|
||||
userDeleteLoading: false,
|
||||
disconnectLoading: null,
|
||||
throttleWarningDismissedOrgs: [],
|
||||
};
|
||||
|
||||
const mePath = { user_id: "me" } as const;
|
||||
@@ -37,6 +39,10 @@ export class UserService extends StatefulService<UserState> {
|
||||
},
|
||||
}));
|
||||
user = computed(() => this.userResource.value());
|
||||
// Tracks throttle warnings dismissed by user in current session
|
||||
throttleWarningDismissedOrgs = computed(
|
||||
() => this.state().throttleWarningDismissedOrgs,
|
||||
);
|
||||
activeUserEmail = computed(() => this.user()?.email);
|
||||
readonly userDeleteError = computed(() => this.state().userDeleteError);
|
||||
readonly userDeleteLoading = computed(() => this.state().userDeleteLoading);
|
||||
@@ -124,6 +130,15 @@ export class UserService extends StatefulService<UserState> {
|
||||
this.userResource.reload();
|
||||
}
|
||||
|
||||
dismissThrottleWarning(orgSlug: string) {
|
||||
const state = this.state();
|
||||
this.setState({
|
||||
throttleWarningDismissedOrgs: state.throttleWarningDismissedOrgs.concat([
|
||||
orgSlug,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
private setUserDeleteLoadingStart() {
|
||||
this.setState({
|
||||
userDeleteLoading: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.matIconRegistry.setDefaultFontSetClass("material-symbols-outlined");
|
||||
this.matIconRegistry.setDefaultFontSetClass("material-symbols-filled");
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
const params = this.route.snapshot.firstChild?.params;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
gt-comments {
|
||||
.delete-loading-overlay {
|
||||
background-color: if(
|
||||
$is-dark-theme,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
rgba(255, 255, 255, 0.5)
|
||||
);
|
||||
}
|
||||
|
||||
.comment-markdown {
|
||||
blockquote {
|
||||
border-left: 5px solid
|
||||
if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette($primary, 300),
|
||||
mat.m2-get-color-from-palette($primary, 200)
|
||||
);
|
||||
// hex colors match prism backgrounds
|
||||
background-color: if($is-dark-theme, #141414, #f5f2f0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,12 @@
|
||||
<div class="comment-header">
|
||||
<div class="comment-user-details">
|
||||
<div>
|
||||
<span class="name mat-subtitle-1">{{
|
||||
<span class="name body-text-strong">{{
|
||||
comment.user?.email
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="date mat-caption">{{
|
||||
<span class="date caption-text">{{
|
||||
comment.dateCreated | date: " LLL d, y hh:mm a"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
z-index: 1002;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--mat-sys-surface-container);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.delete-spinner-wrapper {
|
||||
@@ -60,4 +62,12 @@
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-markdown {
|
||||
blockquote {
|
||||
border-left: 5px solid var(--mat-sys-on-primary-container);
|
||||
background-color: var(--mat-sys-background);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export interface UpdateStatusResponse {
|
||||
|
||||
// not in issues api reference, but is in issue list data
|
||||
|
||||
type StatsPeriod = "24h" | "14d" | "30d" | "";
|
||||
export type StatsPeriod = "24h" | "14d";
|
||||
|
||||
type Stats = { [StatPeriod in StatsPeriod]?: number[][] };
|
||||
|
||||
@@ -336,3 +336,12 @@ interface TopValueAdjusted extends TopValue {
|
||||
percentRounded: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface StatsDataPointBase {
|
||||
name: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface FormattedStatsDataPoint extends StatsDataPointBase {
|
||||
value: number;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$foreground: map.get($theme, foreground);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
gt-event-detail {
|
||||
.pseudo-chip {
|
||||
border: 1px solid mat.m2-get-color-from-palette(mat.$m2-gray-palette, 500);
|
||||
background-color: mat.m2-get-color-from-palette($foreground);
|
||||
|
||||
&:hover {
|
||||
background: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 800),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@
|
||||
<mat-icon class="image">help</mat-icon>
|
||||
}
|
||||
<div class="spacer">
|
||||
<p class="mat-body-strong">{{ contexts.title }}</p>
|
||||
<p class="body-text-strong">{{ contexts.title }}</p>
|
||||
@if (
|
||||
!contexts.title.includes("Unknown") &&
|
||||
contexts.key &&
|
||||
contexts.subtitle
|
||||
) {
|
||||
<p class="mat-small mat-caption">
|
||||
<p class="caption-text caption-text">
|
||||
<span class="bold">{{ contexts.key }}: </span
|
||||
><span class="spacer-small">{{ contexts.subtitle }}</span>
|
||||
</p>
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$foreground: map.get($theme, foreground);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
gt-entry-breadcrumbs {
|
||||
.error {
|
||||
background-color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 800),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
@use "@angular/material" as mat;
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
h3 {
|
||||
padding-top: 32px;
|
||||
@@ -58,6 +58,10 @@ ul {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
@@ -67,6 +71,7 @@ table {
|
||||
width: 24px;
|
||||
padding-right: 8px;
|
||||
|
||||
// TODO: Use theme colors
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
@@ -86,7 +91,7 @@ table {
|
||||
|
||||
.column-three {
|
||||
width: 60%;
|
||||
font-family: $font-mono;
|
||||
font-family: vars.$font-mono;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
gt-entry-exception {
|
||||
.highlighted {
|
||||
background: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 800),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -6,15 +6,16 @@
|
||||
<h3>Exception</h3>
|
||||
@if (group.value === "full-data") {
|
||||
<div class="spacer">
|
||||
(<a mat-button (click)="getFlippedFrames()"
|
||||
>most recent call
|
||||
<button mat-button (click)="getFlippedFrames()">
|
||||
(most recent call
|
||||
@if (isReversed()) {
|
||||
<span>first</span>
|
||||
}
|
||||
@if (isReversed() === false) {
|
||||
<span>last</span>
|
||||
}</a
|
||||
>)
|
||||
}
|
||||
)
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+13
-2
@@ -1,5 +1,5 @@
|
||||
@use "@angular/material" as mat;
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
.exception {
|
||||
margin-bottom: 16px;
|
||||
@@ -10,7 +10,7 @@
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@
|
||||
|
||||
.spacer {
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -78,3 +84,8 @@
|
||||
color: var(--mat-expansion-container-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Adjust color for focus interactions
|
||||
.highlighted {
|
||||
background-color: var(--mat-sys-surface-container);
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
|
||||
gt-frame-expanded {
|
||||
.invalid {
|
||||
color: mat.m2-get-color-from-palette($primary, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
@for (varList of vars | keyvalue; track varList) {
|
||||
<mat-divider></mat-divider>
|
||||
<div class="vars padding">
|
||||
<dt class="mat-body-strong">{{ varList.key }}:</dt>
|
||||
<dt class="body-text-strong">{{ varList.key }}:</dt>
|
||||
<dd>
|
||||
<pre>{{ checkType(varList.value) }}</pre>
|
||||
</dd>
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
@@ -38,7 +38,7 @@ pre {
|
||||
clear: both;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ pre {
|
||||
float: left;
|
||||
width: 20%;
|
||||
|
||||
@media screen and (max-width: $large - 1) {
|
||||
@media screen and (max-width: vars.$large - 1) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ pre {
|
||||
margin-left: 0px;
|
||||
width: 70%;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
@use "@angular/material" as mat;
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
.wrapper {
|
||||
padding: 8px;
|
||||
@@ -24,5 +24,5 @@ p {
|
||||
}
|
||||
|
||||
div {
|
||||
font-family: $font-mono;
|
||||
font-family: vars.$font-mono;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
h3 {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.formatted-message {
|
||||
font-family: $font-mono;
|
||||
font-family: vars.$font-mono;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: normal;
|
||||
word-break: break-all;
|
||||
|
||||
@@ -74,4 +74,4 @@
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const IssueEventList: Event[] = [
|
||||
{ value: "CPython 3.8.0", key: "runtime" },
|
||||
{ value: "CPython", key: "runtime.name" },
|
||||
{ value: "340bbf5db4d1", key: "server_name" },
|
||||
{ value: "https://example.com", key: "url" },
|
||||
],
|
||||
projectID: "1851390",
|
||||
dateCreated: "2020-01-09T14:25:31Z",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@if (initialLoadComplete() && event() === null) {
|
||||
<section class="mat-typography">
|
||||
<h2>Event not found</h2>
|
||||
<p class="mat-body-1">
|
||||
<p class="body-text">
|
||||
This may be because the URL is pointing to an event that doesn't exist or
|
||||
was deleted.
|
||||
</p>
|
||||
@@ -67,10 +67,19 @@
|
||||
[routerLink]="['/', orgSlug(), 'issues']"
|
||||
[queryParams]="generateQuery(tag)"
|
||||
queryParamsHandling="merge"
|
||||
class="pseudo-chip-link"
|
||||
>
|
||||
<span class="pseudo-chip-key">{{ tag.key }}</span>
|
||||
<span class="pseudo-chip-value">{{ tag.value }}</span></a
|
||||
>
|
||||
<span class="pseudo-chip-value">
|
||||
{{ tag.value }}
|
||||
</span>
|
||||
</a>
|
||||
@if (isValidUrl(tag.value)) {
|
||||
<span class="pseudo-chip-divider"></span>
|
||||
<a [href]="tag.value" target="_blank" rel="noopener noreferrer" class="pseudo-chip-external-link">
|
||||
<mat-icon color="primary">open_in_new</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "variables";
|
||||
@use "@angular/material" as mat;
|
||||
@use "variables" as vars;
|
||||
|
||||
section {
|
||||
margin-bottom: 16px;
|
||||
@@ -12,7 +13,7 @@ section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ section {
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
@media screen and (max-width: $small - 1) {
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +67,21 @@ gt-entry-request {
|
||||
float: left;
|
||||
list-style: none;
|
||||
transition: 0.3s;
|
||||
border: 1px solid var(--mat-sys-outline);
|
||||
background-color: var(--mat-sys-surface);
|
||||
color: var(--mat-sys-on-surface);
|
||||
box-shadow: var(--mat-sys-shadow-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mat-sys-surface-container-highest);
|
||||
box-shadow: var(--mat-sys-shadow-2);
|
||||
}
|
||||
}
|
||||
|
||||
.pseudo-chip-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pseudo-chip-key {
|
||||
@@ -77,3 +93,22 @@ gt-entry-request {
|
||||
.pseudo-chip-value {
|
||||
vertical-align: -0.07em;
|
||||
}
|
||||
|
||||
.pseudo-chip-external-link {
|
||||
display: flex !important;
|
||||
max-height: 1em;
|
||||
max-width: 1em;
|
||||
}
|
||||
|
||||
.pseudo-chip-external-link mat-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.pseudo-chip-divider {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1.2em;
|
||||
background-color: var(--mat-sys-outline);
|
||||
margin: 0 8px;
|
||||
}
|
||||
@@ -73,4 +73,17 @@ export class EventDetailComponent {
|
||||
return { query: `${unresolved}"${tag.key}":"${tag.value}"` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid URL
|
||||
*/
|
||||
isValidUrl(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EventDetailComponent } from "./event-detail.component";
|
||||
import { IssueEventList } from "./event-by-issue-test-data";
|
||||
import { signal } from "@angular/core";
|
||||
|
||||
export default {
|
||||
title: "Events/Event Detail/Event Attributes",
|
||||
component: EventDetailComponent,
|
||||
};
|
||||
|
||||
export const EventAttributes = () => {
|
||||
return {
|
||||
props: {
|
||||
orgSlug: signal("test-org"),
|
||||
issueID: signal("12345"),
|
||||
eventID: signal("test-event-id"),
|
||||
event: signal(IssueEventList[0]),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
|
||||
gt-issue-detail-tags {
|
||||
.bar {
|
||||
background: mat.m2-get-color-from-palette(mat.$m2-gray-palette, 400);
|
||||
box-shadow: none;
|
||||
transition: all 0.5s;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: mat.m2-get-color-from-palette($primary, "default");
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
@if (tags()?.length) {
|
||||
<mat-card>
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-content>
|
||||
<h3 class="mat-body-strong">Tags</h3>
|
||||
<h3 class="body-text-strong">Tags</h3>
|
||||
@for (tag of tags(); track tag.key) {
|
||||
<p class="top-value mat-caption">
|
||||
<p class="top-value caption-text">
|
||||
<span class="key">{{ tag.key }}</span>
|
||||
<span>
|
||||
{{ tag.topValues[0].value }}: {{ tag.topValues[0].percentRounded }}%
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "variables";
|
||||
|
||||
mat-card {
|
||||
margin-top: 24px;
|
||||
padding-bottom: 32px;
|
||||
@@ -26,4 +24,26 @@ mat-card {
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
max-width: 190px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: var(--mat-sys-outline-variant);
|
||||
box-shadow: none;
|
||||
transition: all 0.5s;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-primary);
|
||||
box-shadow: var(--mat-sys-shadow-2);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
@if (initialLoadComplete() && issue() === null) {
|
||||
<gt-top-app-bar />
|
||||
<section class="l-body l-body--full-width">
|
||||
@if (organization(); as organization) {
|
||||
<mat-card class="auth-form issue-404">
|
||||
<mat-card-content>
|
||||
<h1 class="mat-headline-4" i18n>Issue not found</h1>
|
||||
<p class="mat-body-1" i18n
|
||||
<h1 class="section-header-text" i18n>Issue not found</h1>
|
||||
<p class="body-text" i18n
|
||||
>This may be because the URL is pointing to an issue that doesn't
|
||||
exist or was deleted.</p
|
||||
>
|
||||
<p class="mat-body-1" i18n
|
||||
<p class="body-text" i18n
|
||||
>It's also possible that this issue was reported to a project that
|
||||
you are not able to access.</p
|
||||
>
|
||||
@@ -26,36 +27,53 @@
|
||||
</section>
|
||||
}
|
||||
@if (issue(); as issue) {
|
||||
<gt-top-app-bar>
|
||||
<ng-container slot="left">
|
||||
<gt-back-link
|
||||
[backLinkText]="
|
||||
isMobile() ? 'Issues' : 'All issues for ' + issue.project.name
|
||||
"
|
||||
[backLinkParams]="generateBackLink(issue.project.id)"
|
||||
/>
|
||||
</ng-container>
|
||||
<ng-container slot="right">
|
||||
<div class="top-bar-actions">
|
||||
@if (isMobile()) {
|
||||
<gt-issue-status-update-dropdown
|
||||
[selectedValue]="issue.status"
|
||||
(optionSelected)="updateIssueStatus($event)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-button-toggle-group
|
||||
[value]="issue.status"
|
||||
(change)="updateIssueStatus($event.value)"
|
||||
>
|
||||
@for (option of statusOptions; track option.value) {
|
||||
<mat-button-toggle [value]="option.value">
|
||||
{{ option.label }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="medium-icon-button"
|
||||
(click)="deleteIssue()"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</gt-top-app-bar>
|
||||
<section class="l-body l-body--full-width">
|
||||
<gt-detail-header
|
||||
[backLinkText]="'View all issues for ' + issue.project.name"
|
||||
[backLinkParams]="generateBackLink(issue.project.id)"
|
||||
[title]="issueTitle()!"
|
||||
[subtitle]="issueSubtitle()"
|
||||
>
|
||||
@if (issue?.status !== "resolved") {
|
||||
<button mat-flat-button color="primary" (click)="markResolved()">
|
||||
<mat-icon aria-hidden="true">done</mat-icon>
|
||||
Resolve
|
||||
</button>
|
||||
}
|
||||
@if (issue?.status !== "unresolved") {
|
||||
<button mat-flat-button color="primary" (click)="markUnresolved()">
|
||||
<mat-icon aria-hidden="true">done</mat-icon>
|
||||
Reopen
|
||||
</button>
|
||||
}
|
||||
@if (issue?.status !== "ignored") {
|
||||
<button mat-stroked-button (click)="markIgnored()"> Ignore </button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="medium-icon-button"
|
||||
(click)="deleteIssue()"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</gt-detail-header>
|
||||
<div class="issue-header-wrapper">
|
||||
<div class="status-indicator" [class]="'level--' + issue.level">
|
||||
<mat-icon>{{ statusIcon() }}</mat-icon>
|
||||
</div>
|
||||
|
||||
<gt-detail-header [title]="issueTitle()!" [subtitle]="issueSubtitle()" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<mat-card class="event-detail-card left">
|
||||
<div class="tab-nav-container">
|
||||
@@ -138,32 +156,32 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<aside class="right">
|
||||
<mat-card>
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-content>
|
||||
<dl>
|
||||
<div>
|
||||
<dt class="mat-caption">Issue #</dt>
|
||||
<dd class="mat-body-1">{{ issue?.shortId }}</dd>
|
||||
<dt class="caption-text">Issue #</dt>
|
||||
<dd class="body-text">{{ issue?.shortId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="mat-caption">Events</dt>
|
||||
<dd class="mat-body-1">{{ issue?.count }}</dd>
|
||||
<dt class="caption-text">Events</dt>
|
||||
<dd class="body-text">{{ issue?.count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="mat-caption">Level</dt>
|
||||
<dd class="mat-body-1">{{ issue.level | titlecase }}</dd>
|
||||
<dt class="caption-text">Level</dt>
|
||||
<dd class="body-text">{{ issue.level | titlecase }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="mat-caption">First Seen</dt>
|
||||
<dd class="mat-body-1">{{ issue.firstSeen | daysAgo }}</dd>
|
||||
<dd class="mat-caption">
|
||||
<dt class="caption-text">First Seen</dt>
|
||||
<dd class="body-text">{{ issue.firstSeen | daysAgo }}</dd>
|
||||
<dd class="caption-text">
|
||||
{{ issue?.firstSeen | date: "long" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="mat-caption">Last Seen</dt>
|
||||
<dd class="mat-body-1">{{ issue.lastSeen | daysAgo }}</dd>
|
||||
<dd class="mat-caption">
|
||||
<dt class="caption-text">Last Seen</dt>
|
||||
<dd class="body-text">{{ issue.lastSeen | daysAgo }}</dd>
|
||||
<dd class="caption-text">
|
||||
{{ issue?.lastSeen | date: "long" }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
.issue-404 {
|
||||
h1 {
|
||||
@@ -38,7 +38,7 @@ dl {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $medium - 1) {
|
||||
@media screen and (max-width: vars.$medium - 1) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ mat-grid-list {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@media screen and (max-width: $medium - 1) {
|
||||
@media screen and (max-width: vars.$medium - 1) {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ mat-grid-list {
|
||||
flex: 75%;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: $medium - 1) {
|
||||
@media screen and (max-width: vars.$medium - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ mat-grid-list {
|
||||
.right {
|
||||
flex: 25%;
|
||||
|
||||
@media screen and (max-width: $medium - 1) {
|
||||
@media screen and (max-width: vars.$medium - 1) {
|
||||
width: 100%;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
@@ -91,3 +91,63 @@ table.mat-table {
|
||||
.report-badge > span {
|
||||
text-overflow: initial;
|
||||
}
|
||||
|
||||
.issue-header-wrapper {
|
||||
display: flex;
|
||||
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-on-primary);
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.level--sample {
|
||||
background-color: var(--mat-sys-outline);
|
||||
}
|
||||
|
||||
&.level--debug {
|
||||
background-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
&.level--info {
|
||||
background-color: var(--info-color);
|
||||
}
|
||||
|
||||
&.level--warning {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
&.level--error {
|
||||
background-color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
&.level--fatal {
|
||||
background-image: repeating-linear-gradient(
|
||||
-60deg,
|
||||
var(--mat-sys-error),
|
||||
var(--mat-sys-error) 5px,
|
||||
var(--mat-sys-surface) 5px,
|
||||
var(--mat-sys-surface) 10px
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
computed,
|
||||
input,
|
||||
effect,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { MatBadgeModule } from "@angular/material/badge";
|
||||
import { MatTabsModule } from "@angular/material/tabs";
|
||||
import { MatCardModule } from "@angular/material/card";
|
||||
import { MatButtonToggleModule } from "@angular/material/button-toggle";
|
||||
import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { DetailHeaderComponent } from "src/app/shared/detail/header/header.component";
|
||||
import { IssueDetailService } from "./issue-detail.service";
|
||||
import { DaysAgoPipe } from "../../shared/days-ago.pipe";
|
||||
@@ -20,6 +24,18 @@ import { MatIconModule } from "@angular/material/icon";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { OrganizationsService } from "src/app/api/organizations.service";
|
||||
import { DatePipe, TitleCasePipe } from "@angular/common";
|
||||
import { BackLinkComponent } from "src/app/shared/detail/back-link/back-link.component";
|
||||
import { TopAppBar } from "src/app/shared/top-app-bar/top-app-bar";
|
||||
import { IssueStatusUpdateDropdownComponent } from "src/app/shared/issue-status-update-dropdown/issue-status-update-dropdown";
|
||||
import { IssueStatus } from "../interfaces";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
resolved: { label: "Resolved", icon: "check_circle" },
|
||||
unresolved: { label: "Unresolved", icon: "error" },
|
||||
ignored: { label: "Ignored", icon: "notifications_off" },
|
||||
} as const;
|
||||
|
||||
type IssueStatusType = keyof typeof STATUS_CONFIG;
|
||||
|
||||
@Component({
|
||||
selector: "gt-issue-detail",
|
||||
@@ -33,17 +49,22 @@ import { DatePipe, TitleCasePipe } from "@angular/common";
|
||||
MatBadgeModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
IssueDetailTagsComponent,
|
||||
TitleCasePipe,
|
||||
DatePipe,
|
||||
DaysAgoPipe,
|
||||
DetailHeaderComponent,
|
||||
BackLinkComponent,
|
||||
TopAppBar,
|
||||
IssueStatusUpdateDropdownComponent,
|
||||
],
|
||||
})
|
||||
export class IssueDetailComponent implements OnInit {
|
||||
private issueService = inject(IssueDetailService);
|
||||
private organizationsService = inject(OrganizationsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
protected breakPointObserver = inject(BreakpointObserver);
|
||||
|
||||
issue = this.issueService.issue;
|
||||
issueTitle = computed(() => {
|
||||
@@ -95,6 +116,20 @@ export class IssueDetailComponent implements OnInit {
|
||||
return issue.culprit as string;
|
||||
}
|
||||
});
|
||||
|
||||
statusIcon = computed(() => {
|
||||
const issue = this.issue();
|
||||
if (!issue) return "error";
|
||||
|
||||
const config = STATUS_CONFIG[issue.status as IssueStatusType];
|
||||
return config?.icon ?? "error";
|
||||
});
|
||||
|
||||
statusOptions = Object.entries(STATUS_CONFIG).map(([value, config]) => ({
|
||||
value,
|
||||
label: config.label,
|
||||
}));
|
||||
|
||||
initialLoadComplete = this.issueService.issueInitialLoadComplete;
|
||||
form = new FormGroup({
|
||||
assignee: new FormControl(""),
|
||||
@@ -107,26 +142,32 @@ export class IssueDetailComponent implements OnInit {
|
||||
other: "# Participants",
|
||||
};
|
||||
|
||||
isMobile = signal(false);
|
||||
smallBreakpointSignal = toSignal(
|
||||
this.breakPointObserver.observe([Breakpoints.Small, Breakpoints.XSmall]),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.issueService.issueID.set(this.issueID());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const breakpointResult = this.smallBreakpointSignal();
|
||||
if (breakpointResult?.matches) {
|
||||
this.isMobile.set(true);
|
||||
} else {
|
||||
this.isMobile.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.issueService.clearState();
|
||||
}
|
||||
|
||||
markResolved() {
|
||||
this.issueService.setStatus("resolved");
|
||||
}
|
||||
|
||||
markUnresolved() {
|
||||
this.issueService.setStatus("unresolved");
|
||||
}
|
||||
|
||||
markIgnored() {
|
||||
this.issueService.setStatus("ignored");
|
||||
updateIssueStatus(status: IssueStatus) {
|
||||
this.issueService.setStatus(status);
|
||||
}
|
||||
|
||||
deleteIssue() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
|
||||
gt-issue-zero-states {
|
||||
.not-on-team {
|
||||
border: 2px solid mat.m2-get-color-from-palette($primary);
|
||||
color: mat.m2-get-color-from-palette($primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="empty-states">
|
||||
@if (displayZeroStates()) {
|
||||
@if (orgHasAProject() === false) {
|
||||
<h3 class="mat-headline-4" i18n>This organization has no projects.</h3>
|
||||
<p class="mat-body-1" i18n
|
||||
<h3 class="section-header-text" i18n>This organization has no projects.</h3>
|
||||
<p class="body-text" i18n
|
||||
>To add a project to this organization, press the
|
||||
"Add Project" button.</p
|
||||
>
|
||||
@@ -31,13 +31,13 @@
|
||||
@if (showOnboarding()) {
|
||||
<div class="install-wrapper">
|
||||
@if (activeProjectPlatform(); as activeProjectPlatform) {
|
||||
<div class="install-instructions mat-body-1">
|
||||
<div class="install-instructions body-text">
|
||||
@if (
|
||||
activeProjectPlatform !== "other" &&
|
||||
activeProjectPlatform !== "" &&
|
||||
activeProjectPlatformName()
|
||||
) {
|
||||
<h2 class="mat-headline-4" i18n
|
||||
<h2 class="section-header-text" i18n
|
||||
>Connect your {{ activeProjectPlatformName() }} project to
|
||||
GlitchTip</h2
|
||||
>
|
||||
@@ -45,7 +45,7 @@
|
||||
src="/static/sdk-docs/{{ activeProjectPlatform }}.md"
|
||||
></markdown>
|
||||
} @else {
|
||||
<h2 class="mat-headline-4" i18n
|
||||
<h2 class="section-header-text" i18n
|
||||
>Connect your project to GlitchTip</h2
|
||||
>
|
||||
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
|
||||
@@ -53,12 +53,12 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="install-instructions mat-body-1">
|
||||
<h2 class="mat-headline-4" i18n
|
||||
<div class="install-instructions body-text">
|
||||
<h2 class="section-header-text" i18n
|
||||
>Connect your project to GlitchTip</h2
|
||||
>
|
||||
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
|
||||
<p class="mat-body-1" i18n
|
||||
<p class="body-text" i18n
|
||||
>If you go to this project's
|
||||
<a
|
||||
[routerLink]="[
|
||||
@@ -77,8 +77,8 @@
|
||||
}
|
||||
@if (firstProjectKey(); as projectKey) {
|
||||
<div class="dsn">
|
||||
<h2 class="mat-headline-4">DSN</h2>
|
||||
<p class="mat-body-1" i18n
|
||||
<h2 class="section-header-text">DSN</h2>
|
||||
<p class="body-text" i18n
|
||||
>Most instructions will have a spot that says
|
||||
"YOUR-GLITCHTIP-DSN-HERE". Copy the DSN and paste it into that
|
||||
spot.</p
|
||||
@@ -96,10 +96,10 @@
|
||||
@if (activeProjectPlatform()) {
|
||||
@if (activeProjectPlatform() !== "other") {
|
||||
<h2 class="mat-body-strong" i18n>What's this all about?</h2>
|
||||
<div class="mat-body-1">
|
||||
<div class="body-text">
|
||||
<markdown src="/static/sdk-docs/all-sdks.md"></markdown>
|
||||
</div>
|
||||
<p class="mat-body-1" i18n
|
||||
<p class="body-text" i18n
|
||||
>This page is showing instructions on how to connect
|
||||
GlitchTip to your project based on the platform you
|
||||
specified.</p
|
||||
@@ -111,15 +111,15 @@
|
||||
</div>
|
||||
} @else {
|
||||
<!-- No issues match the filters, in general -->
|
||||
<h3 class="mat-headline-4" i18n>No events match your filters.</h3>
|
||||
<p class="mat-body-1" i18n
|
||||
<h3 class="section-header-text" i18n>No events match your filters.</h3>
|
||||
<p class="body-text" i18n
|
||||
>Try changing your search parameters to get something to show
|
||||
here.</p
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
<h3 i18n>No events match your filters.</h3>
|
||||
<p class="mat-body-1" i18n
|
||||
<p class="body-text" i18n
|
||||
>You have multiple projects selected. Select a single project to learn
|
||||
more about why there's nothing here.</p
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
.empty-states {
|
||||
padding: 1.5em 0 0.5em;
|
||||
@@ -8,6 +8,8 @@
|
||||
padding: 10px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid var(--mat-sys-primary);
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
markdown {
|
||||
@@ -18,7 +20,7 @@ markdown {
|
||||
.install-wrapper {
|
||||
display: flex;
|
||||
|
||||
@media (max-width: $medium - 1) {
|
||||
@media (max-width: vars.$medium - 1) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -27,11 +29,11 @@ markdown {
|
||||
width: calc(100% - 300px);
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $large - 1) {
|
||||
@media (max-width: vars.$large - 1) {
|
||||
width: calc(100% - 250px);
|
||||
}
|
||||
|
||||
@media (max-width: $medium - 1) {
|
||||
@media (max-width: vars.$medium - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -41,17 +43,17 @@ markdown {
|
||||
padding-left: 30px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $large - 1) {
|
||||
@media (max-width: vars.$large - 1) {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@media (max-width: $medium - 1) {
|
||||
@media (max-width: vars.$medium - 1) {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
.mat-body-1 {
|
||||
.body-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
.issues-page-wrapper {
|
||||
.bulk-project-select {
|
||||
&__inner {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background-color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 900),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 50)
|
||||
);
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 50),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 900)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.resolved {
|
||||
background: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 800),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
|
||||
.ignored {
|
||||
background: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 800),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
|
||||
.level {
|
||||
&--sample:after {
|
||||
background-color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 400),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 100)
|
||||
);
|
||||
}
|
||||
|
||||
&--debug:after {
|
||||
background-color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 600),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 300)
|
||||
);
|
||||
}
|
||||
|
||||
&--info:after {
|
||||
background-color: #4b60b4;
|
||||
}
|
||||
|
||||
&--warning:after {
|
||||
background-color: #e9b949;
|
||||
}
|
||||
|
||||
&--error:after {
|
||||
background-color: mat.m2-get-color-from-palette($primary);
|
||||
}
|
||||
|
||||
&--fatal:after {
|
||||
background-image: repeating-linear-gradient(
|
||||
-60deg,
|
||||
mat.m2-get-color-from-palette($primary),
|
||||
mat.m2-get-color-from-palette($primary) 5px,
|
||||
white 5px,
|
||||
white 10px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="chart-container">
|
||||
<ngx-charts-bar-vertical
|
||||
[results]="formattedStats()"
|
||||
[customColors]="customColors()"
|
||||
[xAxis]="true"
|
||||
[animations]="true"
|
||||
[showDataLabel]="false"
|
||||
[barPadding]="statsPeriod() === '14d' ? 17 : 8"
|
||||
[trimXAxisTicks]="false"
|
||||
[roundEdges]="false"
|
||||
>
|
||||
<ng-template #tooltipTemplate let-model="model">
|
||||
<div class="tooltip">
|
||||
@if (statsPeriod() === "14d") {
|
||||
<!--
|
||||
Daily stats are calculated from midnight on a given day
|
||||
according to the server timezone. So rather than localizing the date,
|
||||
we use the server tz and make that explicit to the user.
|
||||
-->
|
||||
<div>
|
||||
{{ model.name | date: 'M/d/yy': serverTimeZone() }}
|
||||
({{serverTimeZone()}})</div
|
||||
>
|
||||
} @else {
|
||||
<div> {{ model.name | date: 'M/d/yy, h:mm a'}}</div>
|
||||
}
|
||||
<div
|
||||
>{{ model.value | i18nPlural: {'=0': 'events', '=1': '# event',
|
||||
'other': '# events'} }}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ngx-charts-bar-vertical>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
.chart-container {
|
||||
width: 300px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
font: var(--mat-sys-label-medium);
|
||||
padding: 4px;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { DatePipe, I18nPluralPipe } from "@angular/common";
|
||||
import { NgxChartsModule } from "@swimlane/ngx-charts";
|
||||
import {
|
||||
FormattedStatsDataPoint,
|
||||
StatsDataPointBase,
|
||||
StatsPeriod,
|
||||
} from "../../interfaces";
|
||||
|
||||
const HIGH_THRESHOLD_RATIO = 0.8;
|
||||
|
||||
@Component({
|
||||
selector: "gt-issue-chart",
|
||||
standalone: true,
|
||||
imports: [DatePipe, I18nPluralPipe, NgxChartsModule],
|
||||
templateUrl: "./issue-chart.html",
|
||||
styleUrl: "./issue-chart.scss",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class IssueChart {
|
||||
formattedStats = input.required<FormattedStatsDataPoint[]>();
|
||||
statsChartDataFrame = input.required<StatsDataPointBase[]>();
|
||||
statsPeriod = input.required<StatsPeriod>();
|
||||
serverTimeZone = input.required<string | undefined>();
|
||||
loading = input.required<boolean>();
|
||||
|
||||
customColors = computed(() => {
|
||||
const data = this.formattedStats();
|
||||
const max = Math.max(...data.map((d) => d.value), 0);
|
||||
const threshold = max * HIGH_THRESHOLD_RATIO;
|
||||
|
||||
return data
|
||||
.filter((d) => d.value > 0)
|
||||
.map((d) => ({
|
||||
name: d.name,
|
||||
value:
|
||||
d.value >= threshold
|
||||
? "var(--mat-sys-error)"
|
||||
: "var(--mat-sys-secondary)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -1,60 +1,48 @@
|
||||
<gt-list-app-bar
|
||||
listTitle="Issues"
|
||||
[searchHits]="paginator()?.count"
|
||||
[queriedProjects]="projects()"
|
||||
[queriedTimeRangeStart]="start()"
|
||||
[queriedTimeRangeEnd]="end()"
|
||||
[includeTimeRangeSelect]="true"
|
||||
[displayAddProject]="true"
|
||||
/>
|
||||
<section class="l-body l-body--full-width mat-elevation-z0">
|
||||
<header class="page-header">
|
||||
<gt-list-title
|
||||
i18n-title
|
||||
title="Issues"
|
||||
[searchHits]="paginator()?.count"
|
||||
></gt-list-title>
|
||||
</header>
|
||||
<gt-project-filter-bar></gt-project-filter-bar>
|
||||
@if (issues(); as issues) {
|
||||
@if (availableEnvironments(); as availableEnvironments) {
|
||||
<gt-data-filter-bar
|
||||
[environmentForm]="environmentForm"
|
||||
[searchForm]="form"
|
||||
[sortForm]="sortForm"
|
||||
[sorts]="sorts"
|
||||
[organizationEnvironments]="availableEnvironments"
|
||||
(filterByEnvironment)="filterByEnvironment($event)"
|
||||
(searchSubmit)="searchSubmit()"
|
||||
(sortByChanged)="sortByChanged($event)"
|
||||
/>
|
||||
}
|
||||
<div class="table-container">
|
||||
@if (issues(); as issues) {
|
||||
<div class="wrapper issues-page-wrapper">
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="issues"
|
||||
[trackBy]="trackIssues"
|
||||
class="mat-elevation-z2"
|
||||
[class.fixedLayout]="issues.length === 0"
|
||||
[class.fixedLayout]="issues.length === 0 && !loading()"
|
||||
>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th
|
||||
mat-header-cell
|
||||
[attr.colspan]="displayedColumns.length"
|
||||
*matHeaderCellDef
|
||||
class="resolved-row"
|
||||
class="table-header"
|
||||
>
|
||||
@if (availableEnvironments(); as availableEnvironments) {
|
||||
<gt-data-filter-bar
|
||||
[dateForm]="dateForm"
|
||||
[environmentForm]="environmentForm"
|
||||
[searchForm]="form"
|
||||
[sortForm]="sortForm"
|
||||
[sorts]="sorts"
|
||||
[organizationEnvironments]="availableEnvironments"
|
||||
(dateFormSubmission)="onDateFormSubmit($event)"
|
||||
(dateFormReset)="dateFormReset()"
|
||||
(filterByEnvironment)="filterByEnvironment($event)"
|
||||
(searchSubmit)="searchSubmit()"
|
||||
(sortByChanged)="sortByChanged($event)"
|
||||
></gt-data-filter-bar>
|
||||
}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="select">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
[class.sticky-header]="thereAreSelectedIssues() === true"
|
||||
>
|
||||
<ng-container matColumnDef="selectAll">
|
||||
<th mat-header-cell *matHeaderCellDef class="table-header__actions">
|
||||
<mat-checkbox
|
||||
(change)="toggleSelectAllOnPage()"
|
||||
[checked]="areAllSelected()"
|
||||
[disabled]="issues.length === 0"
|
||||
id="selectAll"
|
||||
>
|
||||
</mat-checkbox>
|
||||
data-cy="selectAll"
|
||||
/>
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="select">
|
||||
<!-- Empty header cell -->
|
||||
<th mat-header-cell *matHeaderCellDef> </th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let row"
|
||||
@@ -69,49 +57,98 @@
|
||||
</mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actionButtons">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header
|
||||
[attr.colspan]="displayedColumns().length - 1"
|
||||
class="table-header__actions"
|
||||
>
|
||||
<div class="action-button-container">
|
||||
@if (isLargeScreen()) {
|
||||
<div class="status-button-group">
|
||||
<button
|
||||
data-cy="bulkMarkResolved"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="updateStatus('resolved')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Mark resolved</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="updateStatus('unresolved')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Mark unresolved</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="updateStatus('ignored')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Mark ignored</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="mergeSelectedIssues()"
|
||||
[disabled]="
|
||||
numberOfSelectedIssues() < 2 ||
|
||||
allResultsSelected() ||
|
||||
multipleProjectIssuesSelected()
|
||||
"
|
||||
i18n
|
||||
>Merge</button
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
mat-icon-button
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
[matMenuTriggerFor]="menu"
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">more</mat-icon>
|
||||
</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="updateStatus('resolved')" i18n
|
||||
>Mark resolved</button
|
||||
>
|
||||
<button mat-menu-item (click)="updateStatus('unresolved')" i18n
|
||||
>Mark unresolved</button
|
||||
>
|
||||
<button mat-menu-item (click)="updateStatus('ignored')" i18n
|
||||
>Mark ignored</button
|
||||
>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="mergeSelectedIssues()"
|
||||
[disabled]="
|
||||
numberOfSelectedIssues() < 2 ||
|
||||
allResultsSelected() ||
|
||||
multipleProjectIssuesSelected()
|
||||
"
|
||||
i18n
|
||||
>Merge</button
|
||||
>
|
||||
</mat-menu>
|
||||
}
|
||||
<gt-pagination-buttons
|
||||
[paginator]="paginator()"
|
||||
[loading]="loading()"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="title">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header
|
||||
[class.sticky-header]="thereAreSelectedIssues() === true"
|
||||
class="status-header-cell"
|
||||
>
|
||||
<div class="button-group">
|
||||
<button
|
||||
id="bulkMarkResolved"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="updateStatus('resolved')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Mark as resolved</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="updateStatus('unresolved')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Reopen</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="updateStatus('ignored')"
|
||||
[disabled]="thereAreSelectedIssues() === false"
|
||||
i18n
|
||||
>Ignore</button
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="mergeSelectedIssues()"
|
||||
[disabled]="
|
||||
numberOfSelectedIssues() < 2 ||
|
||||
allResultsSelected() ||
|
||||
multipleProjectIssuesSelected()
|
||||
"
|
||||
i18n
|
||||
>Merge</button
|
||||
>
|
||||
</div>
|
||||
Issue
|
||||
</th>
|
||||
<td
|
||||
mat-cell
|
||||
@@ -120,98 +157,90 @@
|
||||
[class.ignored]="element.status === 'ignored'"
|
||||
class="title-cell"
|
||||
>
|
||||
@if (element.status === "ignored") {
|
||||
<div class="table-group-label">
|
||||
@if (element.status === "ignored") {
|
||||
<mat-icon inline="true" class="mute-icon"> volume_off </mat-icon>
|
||||
}
|
||||
<a [routerLink]="element.id" queryParamsHandling="merge">{{
|
||||
element.title
|
||||
}}</a>
|
||||
<div class="other-cell-info mat-caption">
|
||||
}
|
||||
<a [routerLink]="element.id" queryParamsHandling="merge"
|
||||
>{{ element.title }}</a
|
||||
>
|
||||
</div>
|
||||
<div class="small-screen-event-count caption-text">
|
||||
{{ element.count | i18nPlural: eventCountPluralMapping }}
|
||||
</div>
|
||||
<div>
|
||||
@if (appliedProjectCount() !== 1) {
|
||||
<span class="mat-caption">
|
||||
{{ element.projectSlug }}
|
||||
</span>
|
||||
<span class="caption-text"> {{ element.projectSlug }} </span>
|
||||
}
|
||||
<span class="mat-caption">
|
||||
@if (appliedProjectCount() !== 1) {
|
||||
—
|
||||
}
|
||||
<span class="caption-text">
|
||||
@if (appliedProjectCount() !== 1) { — }
|
||||
<time
|
||||
[dateTime]="element.firstSeen"
|
||||
[title]="element.firstSeen | date: 'medium'"
|
||||
>
|
||||
{{ element.firstSeen | daysOld }}</time
|
||||
>
|
||||
@if (element.firstSeen !== element.lastSeen) {
|
||||
, seen
|
||||
<time
|
||||
[dateTime]="element.lastSeen"
|
||||
[title]="element.lastSeen | date: 'medium'"
|
||||
>
|
||||
{{ element.lastSeen | daysAgo }}
|
||||
</time>
|
||||
@if (element.firstSeen !== element.lastSeen) { , seen
|
||||
<time
|
||||
[dateTime]="element.lastSeen"
|
||||
[title]="element.lastSeen | date: 'medium'"
|
||||
>
|
||||
{{ element.lastSeen | daysAgo }}
|
||||
</time>
|
||||
}
|
||||
</span>
|
||||
@if (element.type && element.type === "csp") {
|
||||
<span class="mat-caption">
|
||||
@if (appliedProjectCount() !== 1 && element.type) {
|
||||
<span> — </span>
|
||||
}
|
||||
{{ element.type }} report</span
|
||||
>
|
||||
<span class="caption-text">
|
||||
@if (appliedProjectCount() !== 1 && element.type) {
|
||||
<span> — </span>
|
||||
} {{ element.type }} report</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="bulkProjectSelect">
|
||||
<td
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
colspan="3"
|
||||
class="bulk-project-select__inner"
|
||||
colspan="4"
|
||||
class="bulk-project-select"
|
||||
>
|
||||
@if (showBulkSelectProject() === true) {
|
||||
@if (allResultsSelected() === false) {
|
||||
<span class="bulk-project-select__text"
|
||||
>{{ numberOfSelectedIssues() }} issues on this page
|
||||
selected.</span
|
||||
>
|
||||
<button
|
||||
id="bulkUpdateProject"
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="selectAllResults()"
|
||||
>
|
||||
@if (paginator()?.hits; as hits) {
|
||||
Select all {{ hits }} issues that match this query
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="bulk-project-select__text"
|
||||
>All {{ paginator()?.hits }} issues are currently
|
||||
selected.</span
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="clearSelectAllResults()"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
@if (showBulkSelectProject() === true) { @if (allResultsSelected()
|
||||
=== false) {
|
||||
<span class="bulk-project-select__text"
|
||||
>{{ numberOfSelectedIssues() }} issues on this page
|
||||
selected.</span
|
||||
>
|
||||
<button
|
||||
data-cy="bulkUpdateProject"
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="selectAllResults()"
|
||||
>
|
||||
@if (paginator()?.hits; as hits) { Select all {{ hits }} issues
|
||||
that match this query }
|
||||
</button>
|
||||
} @else {
|
||||
<span class="bulk-project-select__text"
|
||||
>All {{ paginator()?.hits }} issues are currently selected.</span
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="clearSelectAllResults()"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
} }
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="events">
|
||||
<th
|
||||
mat-header-cell
|
||||
*matHeaderCellDef
|
||||
mat-sort-header
|
||||
class="status-header-cell sticky-header"
|
||||
[class.sticky-header]="thereAreSelectedIssues() === true"
|
||||
class="status-header-cell"
|
||||
>
|
||||
Events
|
||||
</th>
|
||||
@@ -220,51 +249,87 @@
|
||||
*matCellDef="let element"
|
||||
[class.resolved]="element.status === 'resolved'"
|
||||
[class.ignored]="element.status === 'ignored'"
|
||||
class="event-cell mat-body-1"
|
||||
class="event-cell body-text"
|
||||
>
|
||||
<p>{{ element.count }}</p>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="pagination">
|
||||
<ng-container matColumnDef="trend">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<div class="stats-header-content">
|
||||
<div class="stats-header-container">
|
||||
<span>Trend</span>
|
||||
<div class="stats-period-toggle-container">
|
||||
<button
|
||||
class="stats-period-btn"
|
||||
[disabled]="statsPeriod() === '24h'"
|
||||
(click)="toggleStatsPeriod('24h')"
|
||||
>
|
||||
24h
|
||||
</button>
|
||||
<button
|
||||
class="stats-period-btn"
|
||||
[disabled]="statsPeriod() === '14d'"
|
||||
(click)="toggleStatsPeriod('14d')"
|
||||
>
|
||||
14d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<td
|
||||
mat-footer-cell
|
||||
*matFooterCellDef
|
||||
[attr.colspan]="displayedColumns.length"
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
[class.resolved]="element.status === 'resolved'"
|
||||
[class.ignored]="element.status === 'ignored'"
|
||||
>
|
||||
<gt-list-footer [paginator]="paginator()"> </gt-list-footer>
|
||||
<div class="stats-cell-content">
|
||||
<gt-issue-chart
|
||||
[formattedStats]="element.formattedStats"
|
||||
[loading]="issueStatsLoading()"
|
||||
[serverTimeZone]="serverTimeZone()"
|
||||
[statsChartDataFrame]="statsChartDataFrame()"
|
||||
[statsPeriod]="statsPeriod()"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="['actions']"></tr>
|
||||
<tr
|
||||
mat-header-row
|
||||
class="overlay"
|
||||
*matHeaderRowDef="displayedColumns"
|
||||
[class.u-hidden]="issues.length === 0"
|
||||
class="table-header"
|
||||
[hidden]="issues.length === 0 && !loading()"
|
||||
[class.hideLowerBorder]="showBulkSelectProject()"
|
||||
*matHeaderRowDef="['selectAll', 'actionButtons']; sticky: true"
|
||||
></tr>
|
||||
<tr
|
||||
mat-header-row
|
||||
*matHeaderRowDef="['bulkProjectSelect']"
|
||||
[class.bulk-project-select__hide]="showBulkSelectProject() === false"
|
||||
class="table-header"
|
||||
*matHeaderRowDef="['bulkProjectSelect']; sticky: showBulkSelectProject()"
|
||||
[hidden]="!showBulkSelectProject()"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
<tr
|
||||
mat-header-row
|
||||
*matHeaderRowDef="displayedColumns(); sticky: true"
|
||||
[hidden]="(issues.length === 0 && !loading()) || !isLargeScreen()"
|
||||
class="table-header"
|
||||
[class.stickyHack]="showBulkSelectProject()"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns()"></tr>
|
||||
<tr class="mat-row" *matNoDataRow>
|
||||
<td
|
||||
class="mat-cell zero-states-cell"
|
||||
[attr.colspan]="displayedColumns.length"
|
||||
class="zero-states-cell"
|
||||
[attr.colspan]="displayedColumns().length"
|
||||
>
|
||||
@if (initialLoad()) {
|
||||
<gt-issue-zero-states
|
||||
[projects]="projects()"
|
||||
></gt-issue-zero-states>
|
||||
<gt-issue-zero-states
|
||||
[projects]="projects()"
|
||||
></gt-issue-zero-states>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
mat-footer-row
|
||||
class="pagination-row"
|
||||
*matFooterRowDef="['pagination']"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
$iconWidth: 56px;
|
||||
$selectColumnWidth: 60px;
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
@@ -15,77 +16,88 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
button {
|
||||
font-size: 0.95em;
|
||||
line-height: 30px;
|
||||
padding: 0 12px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin: -4px 0;
|
||||
}
|
||||
.table-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 20px 24px 8px;
|
||||
.action-button-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-button-group {
|
||||
button {
|
||||
font-size: 0.95em;
|
||||
line-height: 30px;
|
||||
padding: 0 12px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hideLowerBorder {
|
||||
th {
|
||||
border-color: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-project-select {
|
||||
&__hide {
|
||||
display: none;
|
||||
height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
text-align: center;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&__inner {
|
||||
text-align: center;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
button {
|
||||
margin: 8px;
|
||||
}
|
||||
button {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
font-family: $font-sans-serif;
|
||||
font-family: vars.$font-sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-column-title {
|
||||
max-width: 500px;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
// Fragile hack to fix fact that mat table only successfully adjusts
|
||||
// 'top' rule of sticky table headers on first change of
|
||||
// showBulkSelect() but not on subsequent changes
|
||||
// TODO: Find a way to avoid this.
|
||||
.stickyHack {
|
||||
th {
|
||||
top: 119px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-column-selectAll {
|
||||
width: $selectColumnWidth;
|
||||
}
|
||||
|
||||
.mat-column-select {
|
||||
width: $selectColumnWidth;
|
||||
padding-left: 14px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mat-column-title {
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
|
||||
@media screen and (min-width: vars.$large) {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-column-trend {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.mat-column-events {
|
||||
padding-right: 24px;
|
||||
width: 125px;
|
||||
}
|
||||
|
||||
.zero-states-cell {
|
||||
@@ -103,56 +115,15 @@ table {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
th.mat-header-cell:first-of-type,
|
||||
td.mat-cell:first-of-type,
|
||||
td.mat-footer-cell:first-of-type {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 120px;
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-header-cell,
|
||||
.event-header-cell {
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.event-cell {
|
||||
width: 80px;
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.other-cell-info {
|
||||
.small-screen-event-count {
|
||||
margin: 0 0 -4px;
|
||||
font-weight: 500;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
@media screen and (min-width: vars.$small) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -167,4 +138,71 @@ table {
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
&--sample:after {
|
||||
background-color: var(--mat-sys-outline);
|
||||
}
|
||||
&--debug:after {
|
||||
background-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
&--info:after {
|
||||
background-color: var(--info-color);
|
||||
}
|
||||
&--warning:after {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
&--error:after {
|
||||
background-color: var(--mat-sys-error);
|
||||
}
|
||||
&--fatal:after {
|
||||
background-image: repeating-linear-gradient(
|
||||
-60deg,
|
||||
var(--mat-sys-error),
|
||||
var(--mat-sys-error) 5px,
|
||||
var(--mat-sys-surface) 5px,
|
||||
var(--mat-sys-surface) 10px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-cell-content,
|
||||
.stats-header-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 270px;
|
||||
}
|
||||
|
||||
.stats-period-toggle-container {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
.stats-period-btn {
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
|
||||
&:disabled {
|
||||
font-weight: bold;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
&:enabled {
|
||||
color: var(--mat-sys-outline);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mat-sys-outline-variant);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout";
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { DatePipe, I18nPluralPipe } from "@angular/common";
|
||||
import { FormControl, FormGroup } from "@angular/forms";
|
||||
@@ -15,44 +18,51 @@ import { MatCheckboxModule } from "@angular/material/checkbox";
|
||||
import { MatDialog } from "@angular/material/dialog";
|
||||
import { MatIconModule } from "@angular/material/icon";
|
||||
import { MatSelectChange } from "@angular/material/select";
|
||||
import { MatTableModule } from "@angular/material/table";
|
||||
import { MatTable, MatTableModule } from "@angular/material/table";
|
||||
import { Router, ActivatedRoute, RouterLink } from "@angular/router";
|
||||
import { IssuesService } from "../issues.service";
|
||||
import { SettingsService } from "src/app/api/settings.service";
|
||||
import { IssueStatus } from "../interfaces";
|
||||
import { DaysAgoPipe, DaysOldPipe } from "../../shared/days-ago.pipe";
|
||||
import { IssueZeroStatesComponent } from "../issue-zero-states/issue-zero-states.component";
|
||||
import { ListFooterComponent } from "../../list-elements/list-footer/list-footer.component";
|
||||
import { DataFilterBarComponent } from "../../list-elements/data-filter-bar/data-filter-bar.component";
|
||||
import { ProjectFilterBarComponent } from "../../list-elements/project-filter-bar/project-filter-bar.component";
|
||||
import { ListTitleComponent } from "../../list-elements/list-title/list-title.component";
|
||||
import { OrganizationsService } from "src/app/api/organizations.service";
|
||||
|
||||
import { MatCardModule } from "@angular/material/card";
|
||||
import { MatButtonToggleModule } from "@angular/material/button-toggle";
|
||||
import {
|
||||
stringArrAttribute,
|
||||
stringAttribute,
|
||||
} from "src/app/shared/shared.utils";
|
||||
import { ConfirmDialogComponent } from "src/app/shared/confirm-dialog/confirm-dialog.component";
|
||||
import { EnvironmentsService } from "src/app/api/environments.service";
|
||||
import { ListAppBar } from "src/app/list-elements/list-app-bar/list-app-bar";
|
||||
import { IssueChart } from "./charts/issue-chart";
|
||||
import { PaginationButtons } from "src/app/list-elements/pagination-buttons/pagination-buttons";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { MatMenuModule } from "@angular/material/menu";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./issues-page.html",
|
||||
styleUrls: ["./issues-page.scss"],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ListTitleComponent,
|
||||
ProjectFilterBarComponent,
|
||||
MatTableModule,
|
||||
DataFilterBarComponent,
|
||||
MatCheckboxModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
RouterLink,
|
||||
ListFooterComponent,
|
||||
IssueZeroStatesComponent,
|
||||
DatePipe,
|
||||
DaysAgoPipe,
|
||||
DaysOldPipe,
|
||||
I18nPluralPipe,
|
||||
ListAppBar,
|
||||
IssueChart,
|
||||
MatButtonToggleModule,
|
||||
PaginationButtons,
|
||||
],
|
||||
providers: [IssuesService],
|
||||
})
|
||||
@@ -61,8 +71,11 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
protected service = inject(IssuesService);
|
||||
protected router = inject(Router);
|
||||
protected route = inject(ActivatedRoute);
|
||||
protected breakPointObserver = inject(BreakpointObserver);
|
||||
private organizationsService = inject(OrganizationsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
#environmentsService = inject(EnvironmentsService);
|
||||
@ViewChild(MatTable) table?: MatTable<any>;
|
||||
|
||||
orgSlug = input.required<string>({ alias: "org-slug" });
|
||||
cursor = input(undefined, { transform: stringAttribute });
|
||||
@@ -73,7 +86,6 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
projects = input([], { alias: "project", transform: stringArrAttribute });
|
||||
environment = input(undefined, { transform: stringAttribute });
|
||||
|
||||
displayedColumns: string[] = ["select", "title", "events"];
|
||||
paginator = this.service.paginator;
|
||||
loading = this.service.loading;
|
||||
initialLoad = this.service.initialLoad;
|
||||
@@ -95,6 +107,7 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
issues = this.service.issuesWithSelected;
|
||||
|
||||
areAllSelected = this.service.areAllSelected;
|
||||
multipleProjectIssuesSelected = computed(() => {
|
||||
let selectedProjects = this.issues()
|
||||
@@ -103,6 +116,23 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
let selectedProjectsSet = new Set(selectedProjects);
|
||||
return selectedProjectsSet.size > 1;
|
||||
});
|
||||
|
||||
allColumns = ["select", "title", "trend", "events"];
|
||||
xSmallScreenColumns = ["select", "title"];
|
||||
mediumScreenColumns = ["select", "title", "events"];
|
||||
isLargeScreen = signal(true);
|
||||
displayedColumns = signal(this.allColumns);
|
||||
breakPointSignal = toSignal(
|
||||
this.breakPointObserver.observe([
|
||||
Breakpoints.Medium,
|
||||
Breakpoints.Small,
|
||||
Breakpoints.XSmall,
|
||||
]),
|
||||
);
|
||||
statsPeriod = this.service.statsPeriod;
|
||||
statsChartDataFrame = this.service.statsChartDataFrame;
|
||||
issueStatsLoading = this.service.issueStatsLoading;
|
||||
serverTimeZone = this.settingsService.serverTimeZone;
|
||||
thereAreSelectedIssues = this.service.thereAreSelectedIssues;
|
||||
allResultsSelected = this.service.allResultsSelected;
|
||||
numberOfSelectedIssues = this.service.numberOfSelectedIssues;
|
||||
@@ -142,10 +172,6 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
availableEnvironments = computed(() =>
|
||||
this.#environmentsService.environmentNames(),
|
||||
);
|
||||
// this.appliedProjectCount() !== 1
|
||||
// ? this.organizationDetailService.organizationEnvironmentsProcessed()
|
||||
// : this.projectEnvironmentsService.visibleEnvironments(),
|
||||
// );
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -223,13 +249,27 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
environment: environment ?? "",
|
||||
});
|
||||
});
|
||||
// Ensure sticky headers stay properly aligned
|
||||
// After showing/hiding headers based on whether or
|
||||
// not there are issue query results
|
||||
effect(() => {
|
||||
const start = this.start();
|
||||
const end = this.end();
|
||||
this.dateForm.setValue({
|
||||
startDate: start ? new Date(start.replace("Z", "")) : null,
|
||||
endDate: end ? new Date(end.replace("Z", "")) : null,
|
||||
});
|
||||
this.issues();
|
||||
if (this.table) {
|
||||
this.table.updateStickyHeaderRowStyles();
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const breakPointResult = this.breakPointSignal();
|
||||
if (breakPointResult?.matches) {
|
||||
this.displayedColumns.set(this.mediumScreenColumns);
|
||||
if (breakPointResult.breakpoints[Breakpoints.XSmall]) {
|
||||
this.displayedColumns.set(this.xSmallScreenColumns);
|
||||
}
|
||||
this.isLargeScreen.set(false);
|
||||
} else {
|
||||
this.displayedColumns.set(this.allColumns);
|
||||
this.isLargeScreen.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,25 +285,6 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
return issue.id;
|
||||
}
|
||||
|
||||
onDateFormSubmit(queryParams: object) {
|
||||
this.router.navigate([], {
|
||||
queryParams,
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
|
||||
dateFormReset() {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
cursor: null,
|
||||
start: null,
|
||||
end: null,
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
this.dateForm.setValue({ startDate: null, endDate: null });
|
||||
}
|
||||
|
||||
searchSubmit() {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
@@ -343,4 +364,8 @@ export class IssuesPage implements OnInit, OnDestroy {
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatsPeriod(period: "24h" | "14d") {
|
||||
this.statsPeriod.set(period);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { client } from "../shared/api/api";
|
||||
import { StatefulService } from "../shared/stateful-service/signal-state.service";
|
||||
import { getPaginationHeaders, getPaginator } from "../shared/pagination.utils";
|
||||
import { IssueStatus } from "./interfaces";
|
||||
import { IssueStatus, StatsPeriod } from "./interfaces";
|
||||
import { apiResource } from "../shared/api/api-resource-factory";
|
||||
|
||||
export interface DataParams {
|
||||
@@ -56,6 +56,8 @@ export class IssuesService extends StatefulService<IssuesState> {
|
||||
protected route = inject(ActivatedRoute);
|
||||
private params = signal<DataParams | undefined>(undefined);
|
||||
|
||||
statsPeriod = signal<StatsPeriod>("24h");
|
||||
|
||||
private issuesResource = resource({
|
||||
params: () => ({ params: this.params() }),
|
||||
loader: async ({ params }) => {
|
||||
@@ -116,27 +118,90 @@ export class IssuesService extends StatefulService<IssuesState> {
|
||||
private issueStatsParams = computed(() => {
|
||||
const params = this.params();
|
||||
const issues = this.issues();
|
||||
const statsPeriod = this.statsPeriod();
|
||||
if (!params || !issues) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
issueIDs: issues.map((issue) => parseInt(issue.id)),
|
||||
orgSlug: params.orgSlug,
|
||||
statsPeriod,
|
||||
};
|
||||
});
|
||||
private issueStatsResource = apiResource(this.issueStatsParams, (params) => ({
|
||||
url: "/api/0/organizations/{organization_slug}/issues-stats/",
|
||||
options: {
|
||||
params: {
|
||||
path: {
|
||||
organization_slug: params.orgSlug,
|
||||
},
|
||||
query: {
|
||||
groups: params.issueIDs,
|
||||
private issueStatsResource = apiResource(this.issueStatsParams, (params) => {
|
||||
const queryParams: any = {
|
||||
groups: params.issueIDs,
|
||||
statsPeriod: params.statsPeriod,
|
||||
};
|
||||
|
||||
return {
|
||||
url: "/api/0/organizations/{organization_slug}/issues-stats/",
|
||||
options: {
|
||||
params: {
|
||||
path: {
|
||||
organization_slug: params.orgSlug,
|
||||
},
|
||||
query: queryParams,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
// Array of timestamps across selected period
|
||||
// counting back from present. Used for empty chart state
|
||||
// and as basis for formatted chart data
|
||||
statsChartDataFrame = computed(() => {
|
||||
const period = this.statsPeriod();
|
||||
const count = this.statsPeriod() === "14d" ? 14 : 24;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const baseTimeRef = new Date();
|
||||
const interval = i + 1 - count;
|
||||
let utcTimestamp: number;
|
||||
if (period === "14d") {
|
||||
utcTimestamp = Date.UTC(
|
||||
baseTimeRef.getUTCFullYear(),
|
||||
baseTimeRef.getUTCMonth(),
|
||||
baseTimeRef.getUTCDate() + interval,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
utcTimestamp = Date.UTC(
|
||||
baseTimeRef.getUTCFullYear(),
|
||||
baseTimeRef.getUTCMonth(),
|
||||
baseTimeRef.getUTCDate(),
|
||||
baseTimeRef.getUTCHours() + interval,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: utcTimestamp.toString(),
|
||||
timestamp: utcTimestamp,
|
||||
};
|
||||
});
|
||||
});
|
||||
formattedStatsChartData = computed(() => {
|
||||
const statsResource = this.issueStatsResource.value();
|
||||
const frameArray = this.statsChartDataFrame();
|
||||
const period = this.statsPeriod();
|
||||
if (!statsResource) {
|
||||
return [];
|
||||
}
|
||||
return statsResource.map((issue) => {
|
||||
const stats = period in issue.stats ? issue.stats[period] : null;
|
||||
const mappedStats = new Map<number, number>(
|
||||
stats?.map(([ts, val]) => [ts, val]),
|
||||
);
|
||||
const formattedStats = frameArray.map((datapoint) => {
|
||||
return {
|
||||
...datapoint,
|
||||
value: mappedStats.get(datapoint.timestamp / 1000) ?? 0,
|
||||
};
|
||||
});
|
||||
return { ...issue, formattedStats };
|
||||
});
|
||||
});
|
||||
|
||||
pagination = computed(() => this.issuesResource.value()?.pagination);
|
||||
paginator = computed(() => getPaginator(this.pagination()));
|
||||
@@ -149,12 +214,11 @@ export class IssuesService extends StatefulService<IssuesState> {
|
||||
if (!issues) {
|
||||
return [];
|
||||
}
|
||||
// Overwrite stats from the issue API (which is always empty and for compat only)
|
||||
return issues.map((issue) => ({
|
||||
...issue,
|
||||
stats:
|
||||
this.issueStatsResource.value()?.find((stat) => stat.id === issue.id)
|
||||
?.stats || issue.stats,
|
||||
formattedStats:
|
||||
this.formattedStatsChartData().find((stat) => stat.id === issue.id)
|
||||
?.formattedStats || [],
|
||||
}));
|
||||
});
|
||||
selectedIssues = computed(() => this.state().selectedIssues);
|
||||
|
||||
@@ -1,76 +1,30 @@
|
||||
@if (dateForm) {
|
||||
@if (sortForm) {
|
||||
<div class="action-group">
|
||||
<form
|
||||
class="date-form"
|
||||
[formGroup]="dateForm"
|
||||
(ngSubmit)="onDateFormSubmit()"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Start Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="startPicker"
|
||||
formControlName="startDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="startPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #startPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="endPicker"
|
||||
formControlName="endDate"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="endPicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<button
|
||||
type="submit"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="
|
||||
!(dateForm.value.startDate || dateForm.value.endDate) ||
|
||||
dateForm.pristine
|
||||
"
|
||||
@if (sortForm) {
|
||||
<div class="data-filter-bar-container">
|
||||
<!-- Changes position based on screen size -->
|
||||
<ng-template #searchFormTemplate>
|
||||
@if (searchForm) {
|
||||
<form
|
||||
[formGroup]="searchForm"
|
||||
(ngSubmit)="searchSubmit.emit()"
|
||||
class="search-form"
|
||||
data-cy="list-search-form"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="dateFormReset.emit()"
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
[disabled]="!dateForm.value.startDate && !dateForm.value.endDate"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
<form [formGroup]="sortForm" class="sort-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sort by</mat-label>
|
||||
<mat-select
|
||||
(selectionChange)="sortByChanged.emit($event)"
|
||||
formControlName="sort"
|
||||
>
|
||||
@for (sort of sorts(); track sort.param) {
|
||||
<mat-option [value]="sort.param">
|
||||
{{ sort.display }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Search</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
formControlName="query"
|
||||
data-cy="list-search-field"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="leftBlock">
|
||||
@if (environmentForm) {
|
||||
<form [formGroup]="environmentForm" class="environment-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Environment</mat-label>
|
||||
<mat-select
|
||||
(selectionChange)="filterByEnvironment.emit($event)"
|
||||
@@ -89,24 +43,27 @@
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
@if (searchForm) {
|
||||
<form
|
||||
[formGroup]="searchForm"
|
||||
(ngSubmit)="searchSubmit.emit()"
|
||||
class="search-form"
|
||||
data-cy="list-search-form"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
formControlName="query"
|
||||
data-cy="list-search-field"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
@if (isLargeScreen()) {
|
||||
<ng-container *ngTemplateOutlet="searchFormTemplate"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form [formGroup]="sortForm" class="sort-form">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Sort by</mat-label>
|
||||
<mat-select
|
||||
(selectionChange)="sortByChanged.emit($event)"
|
||||
formControlName="sort"
|
||||
>
|
||||
@for (sort of sorts(); track sort.param) {
|
||||
<mat-option [value]="sort.param">
|
||||
{{ sort.display }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
@if (!isLargeScreen()) {
|
||||
<ng-container *ngTemplateOutlet="searchFormTemplate"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,104 +1,54 @@
|
||||
@import "variables";
|
||||
@use "variables" as vars;
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
button {
|
||||
font-size: 0.95em;
|
||||
line-height: 30px;
|
||||
padding: 0 12px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-right: 10px;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-group {
|
||||
.data-filter-bar-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 15px;
|
||||
gap: 15px;
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
flex-direction: column-reverse;
|
||||
@media (max-width: vars.$small - 1) {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
> * {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
.sort-form,
|
||||
.environment-form {
|
||||
@media (max-width: vars.$medium - 1) {
|
||||
mat-form-field {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.environment-form,
|
||||
.sort-form {
|
||||
::ng-deep .mat-form-field-infix {
|
||||
width: 130px;
|
||||
@media (max-width: vars.$small - 1) {
|
||||
mat-form-field {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-form,
|
||||
.search-form {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.search-form {
|
||||
@media (min-width: vars.$small) {
|
||||
mat-form-field {
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
}
|
||||
@media (min-width: vars.$large) {
|
||||
mat-form-field {
|
||||
width: 450px;
|
||||
}
|
||||
}
|
||||
@media (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-form {
|
||||
padding-right: 20px;
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
form.date-form {
|
||||
.leftBlock {
|
||||
@media (min-width: vars.$medium) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
> mat-form-field {
|
||||
width: 140px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: -14px;
|
||||
margin-right: 5px;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatDate } from "@angular/common";
|
||||
import { Component, Input, input, output } from "@angular/core";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import { Component, inject, Input, input, output, signal } from "@angular/core";
|
||||
import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout";
|
||||
import { FormGroup, ReactiveFormsModule } from "@angular/forms";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { MatNativeDateModule, MatOptionModule } from "@angular/material/core";
|
||||
@@ -17,12 +18,12 @@ import { MatSelectChange, MatSelectModule } from "@angular/material/select";
|
||||
ReactiveFormsModule,
|
||||
MatOptionModule,
|
||||
MatSelectModule,
|
||||
NgTemplateOutlet,
|
||||
],
|
||||
templateUrl: "./data-filter-bar.component.html",
|
||||
styleUrls: ["./data-filter-bar.component.scss"],
|
||||
})
|
||||
export class DataFilterBarComponent {
|
||||
@Input() dateForm?: FormGroup;
|
||||
@Input() sortForm?: FormGroup;
|
||||
readonly sorts = input<
|
||||
{
|
||||
@@ -32,41 +33,24 @@ export class DataFilterBarComponent {
|
||||
>();
|
||||
@Input() environmentForm?: FormGroup;
|
||||
@Input() searchForm?: FormGroup;
|
||||
protected breakPointObserver = inject(BreakpointObserver);
|
||||
readonly organizationEnvironments = input<string[]>([]);
|
||||
|
||||
readonly dateFormSubmission = output<object>();
|
||||
readonly dateFormReset = output();
|
||||
readonly filterByEnvironment = output<MatSelectChange>();
|
||||
readonly searchSubmit = output();
|
||||
readonly sortByChanged = output<MatSelectChange>();
|
||||
|
||||
convertToZTime(date: Date) {
|
||||
return formatDate(date, "yyyy-MM-ddTHH:mm:ss.SSS", "en-US") + "Z";
|
||||
}
|
||||
isLargeScreen = signal(true);
|
||||
|
||||
onDateFormSubmit() {
|
||||
const startDate = this.dateForm?.value.startDate
|
||||
? this.convertToZTime(this.dateForm?.value.startDate)
|
||||
: null;
|
||||
|
||||
const endDateValue = this.dateForm?.value.endDate;
|
||||
let endDate = null;
|
||||
|
||||
if (endDateValue) {
|
||||
const modifiedEndDate = new Date(endDateValue);
|
||||
|
||||
/**
|
||||
* End dates come in at midnight, so if you pick May 5, you don't get events
|
||||
* from May 5. Bumping it to 23:59:59.999 fixes this
|
||||
*/
|
||||
modifiedEndDate.setHours(23, 59, 59, 999);
|
||||
endDate = this.convertToZTime(modifiedEndDate);
|
||||
}
|
||||
|
||||
this.dateFormSubmission.emit({
|
||||
cursor: null,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
});
|
||||
constructor() {
|
||||
this.breakPointObserver
|
||||
.observe([Breakpoints.Small, Breakpoints.XSmall])
|
||||
.subscribe((result) => {
|
||||
if (result.matches) {
|
||||
this.isLargeScreen.set(false);
|
||||
} else {
|
||||
this.isLargeScreen.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<ng-template #filters>
|
||||
@if (queriedProjects(); as queriedProjects) {
|
||||
<div class="project-multiselect-container">
|
||||
@if (displayAddProject() && activeOrgHasNoProjects()) {
|
||||
<a
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!accessProjectWrite()"
|
||||
class="add-project-button"
|
||||
[routerLink]="['/', activeOrgSlug(), 'settings', 'projects', 'new']"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<ng-container i18n>Add Project</ng-container>
|
||||
</a>
|
||||
} @else {
|
||||
<gt-project-multiselect [queriedProjects]="queriedProjects" />
|
||||
}
|
||||
</div>
|
||||
} @if (includeTimeRangeSelect()) {
|
||||
<div class="time-range-select-container">
|
||||
<gt-time-range-select
|
||||
[queriedTimeRangeStart]="queriedTimeRangeStart()"
|
||||
[queriedTimeRangeEnd]="queriedTimeRangeEnd()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="list-app-bar">
|
||||
<div class="list-app-bar-primary-content">
|
||||
<div class="left-section">
|
||||
<gt-list-title
|
||||
i18n-title
|
||||
[title]="listTitle()"
|
||||
[searchHits]="searchHits()"
|
||||
></gt-list-title>
|
||||
</div>
|
||||
@if (activeOrgLoaded()) {
|
||||
<div class="right-section">
|
||||
@if (displayAddProject() && activeOrgHasNoProjects()) {
|
||||
<a
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!accessProjectWrite()"
|
||||
class="add-project-button"
|
||||
[routerLink]="['/', activeOrgSlug(), 'settings', 'projects', 'new']"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<ng-container i18n>Add Project</ng-container>
|
||||
</a>
|
||||
} @else { @if (isLargeScreen()) {
|
||||
<ng-container *ngTemplateOutlet="filters" />
|
||||
} @else {
|
||||
<button
|
||||
matIconButton
|
||||
[matBadge]="filtersApplied() ? filtersApplied() : null"
|
||||
matBadgeSize="medium"
|
||||
(click)="toggleExpandedFilters()"
|
||||
>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
</button>
|
||||
} }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (showExpandedFilters()) {
|
||||
<div class="list-app-bar-expanded-content">
|
||||
<ng-container *ngTemplateOutlet="filters" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
@use "variables" as vars;
|
||||
|
||||
// Ensure that layout stays aligned between this component
|
||||
// and top-app-bar, on which it is based
|
||||
|
||||
.list-app-bar-primary-content {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
align-content: center;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.left-section,
|
||||
.right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.right-section:has(*) {
|
||||
border-left: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.list-app-bar-expanded-content {
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.add-project-button {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: vars.$medium) {
|
||||
.project-multiselect-container {
|
||||
padding: 0 24px 0 0;
|
||||
}
|
||||
|
||||
.time-range-select-container {
|
||||
padding: 0 0 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: vars.$small) {
|
||||
@media screen and (max-width: vars.$medium - 1) {
|
||||
.list-app-bar-expanded-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.project-multiselect-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.time-range-select-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
// Slightly inconsistent with top-app-bar because
|
||||
// vertical space is at a premium
|
||||
.list-app-bar-primary-content {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.project-multiselect-container {
|
||||
padding: 0 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.time-range-select-container {
|
||||
padding: 0 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { BreakpointObserver, Breakpoints } from "@angular/cdk/layout";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { MatButton, MatIconButton } from "@angular/material/button";
|
||||
import { ProjectMultiselect } from "../project-multiselect/project-multiselect";
|
||||
import { ListTitleComponent } from "../list-title/list-title.component";
|
||||
import { OrganizationsService } from "src/app/api/organizations.service";
|
||||
import { MatIcon } from "@angular/material/icon";
|
||||
import { TimeRangeSelect } from "../timerange-select/time-range-select";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import { MatBadge } from "@angular/material/badge";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "gt-list-app-bar",
|
||||
imports: [
|
||||
MatBadge,
|
||||
MatButton,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
NgTemplateOutlet,
|
||||
ListTitleComponent,
|
||||
ProjectMultiselect,
|
||||
RouterLink,
|
||||
TimeRangeSelect,
|
||||
],
|
||||
templateUrl: "./list-app-bar.html",
|
||||
styleUrl: "./list-app-bar.scss",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ListAppBar {
|
||||
private organizationsService = inject(OrganizationsService);
|
||||
protected breakPointObserver = inject(BreakpointObserver);
|
||||
|
||||
listTitle = input("");
|
||||
searchHits = input<string>();
|
||||
// Project-multiselect will not be shown if not provided
|
||||
queriedProjects = input<string[]>();
|
||||
includeTimeRangeSelect = input(false);
|
||||
queriedTimeRangeStart = input<string | undefined>();
|
||||
queriedTimeRangeEnd = input<string | undefined>();
|
||||
// Determines whether or not to show "add project" button
|
||||
// instead of project-multiselect, for when org has no projects
|
||||
displayAddProject = input(false);
|
||||
filtersApplied = computed(() => {
|
||||
let count = 0;
|
||||
if (this.queriedProjects()?.length) {
|
||||
count += 1;
|
||||
}
|
||||
if (this.queriedTimeRangeStart() || this.queriedTimeRangeEnd()) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
activeOrgLoaded = this.organizationsService.activeOrganizationLoaded;
|
||||
activeOrgHasNoProjects = computed(
|
||||
() => this.organizationsService.activeOrganizationProjects().length === 0,
|
||||
);
|
||||
activeOrgSlug = this.organizationsService.activeOrganizationSlug;
|
||||
accessProjectWrite = this.organizationsService.accessProjectWrite;
|
||||
|
||||
smallBreakpointSignal = toSignal(
|
||||
this.breakPointObserver.observe([Breakpoints.Small, Breakpoints.XSmall]),
|
||||
);
|
||||
isLargeScreen = signal(true);
|
||||
showExpandedFilters = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const breakpointResult = this.smallBreakpointSignal();
|
||||
if (breakpointResult?.matches) {
|
||||
this.isLargeScreen.set(false);
|
||||
} else {
|
||||
this.isLargeScreen.set(true);
|
||||
}
|
||||
this.showExpandedFilters.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
toggleExpandedFilters() {
|
||||
this.showExpandedFilters.set(!this.showExpandedFilters());
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
@import "variables";
|
||||
|
||||
.footer {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
|
||||
.spinner-container {
|
||||
height: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet - 1) {
|
||||
justify-content: space-between;
|
||||
|
||||
.button-left {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.button-right {
|
||||
padding-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 35px;
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
line-height: 56px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@if (title) {
|
||||
<h1 class="mat-headline-4" data-cy="list-title">
|
||||
<h1 class="section-header-text" data-cy="list-title">
|
||||
{{ title }}
|
||||
@if (searchHits) {
|
||||
({{ searchHits }})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.mat-headline-4 {
|
||||
margin-bottom: 0px;
|
||||
.section-header-text {
|
||||
margin: 0px;
|
||||
}
|
||||
+8
-9
@@ -1,23 +1,22 @@
|
||||
<div class="footer">
|
||||
@if (paginator === undefined || paginator.loading) {
|
||||
<div class="pagination-buttons-container">
|
||||
@if (paginator === undefined || loading) {
|
||||
<div class="spinner-container">
|
||||
<mat-progress-spinner
|
||||
[diameter]="33"
|
||||
[diameter]="24"
|
||||
color="primary"
|
||||
mode="indeterminate"
|
||||
></mat-progress-spinner>
|
||||
</div>
|
||||
}
|
||||
@if (paginator) {
|
||||
} @else {
|
||||
<button
|
||||
class="button-left"
|
||||
[routerLink]="[]"
|
||||
[queryParams]="paginator.previousPageParams || {}"
|
||||
queryParamsHandling="merge"
|
||||
mat-button
|
||||
matIconButton
|
||||
[disabled]="paginator.hasPreviousPage === false"
|
||||
>
|
||||
<mat-icon>keyboard_arrow_left</mat-icon>
|
||||
<mat-icon class="page-icon">keyboard_arrow_left</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
data-cy="keyboard-right"
|
||||
@@ -25,10 +24,10 @@
|
||||
[routerLink]="[]"
|
||||
[queryParams]="paginator.nextPageParams || {}"
|
||||
queryParamsHandling="merge"
|
||||
mat-button
|
||||
matIconButton
|
||||
[disabled]="paginator.hasNextPage === false"
|
||||
>
|
||||
<mat-icon>keyboard_arrow_right</mat-icon>
|
||||
<mat-icon class="page-icon">keyboard_arrow_right</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
.pagination-buttons-container {
|
||||
width: 100px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
+6
-5
@@ -6,22 +6,23 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
@Component({
|
||||
selector: "gt-list-footer",
|
||||
selector: "gt-pagination-buttons",
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
RouterModule,
|
||||
],
|
||||
templateUrl: "./list-footer.component.html",
|
||||
styleUrls: ["./list-footer.component.scss"],
|
||||
templateUrl: "./pagination-buttons.html",
|
||||
styleUrls: ["./pagination-buttons.scss"],
|
||||
})
|
||||
export class ListFooterComponent {
|
||||
export class PaginationButtons {
|
||||
@Input() paginator?: {
|
||||
loading?: boolean;
|
||||
previousPageParams: { [key: string]: string[] } | null;
|
||||
hasPreviousPage: boolean;
|
||||
nextPageParams: { [key: string]: string[] } | null;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
@Input() loading?: boolean;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
@use "sass:map";
|
||||
@use "@angular/material" as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$primary: map.get($theme, primary);
|
||||
$is-dark-theme: map.get($theme, is-dark);
|
||||
|
||||
gt-project-filter-bar {
|
||||
.mat-expansion-panel-header.mat-expansion-panel-header[aria-disabled="true"].disabled-looking {
|
||||
color: if(
|
||||
$is-dark-theme,
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 700),
|
||||
mat.m2-get-color-from-palette(mat.$m2-gray-palette, 400)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
@if (projects(); as projects) {
|
||||
<section [class.no-projects]="projects?.length === 0">
|
||||
<div [class.no-projects__picker]="!projects.length" id="project-picker">
|
||||
<mat-accordion displayMode="flat" [hideToggle]="projects.length < 2">
|
||||
<mat-expansion-panel
|
||||
#expansionPanel
|
||||
class="mat-elevation-z1"
|
||||
(afterExpand)="focusPanel()"
|
||||
[disabled]="projects.length < 2"
|
||||
>
|
||||
<mat-expansion-panel-header
|
||||
[class.disabled-looking]="projects.length === 0"
|
||||
>
|
||||
<mat-panel-title class="mat-body-1">
|
||||
{{ selectedProjectsString() }}
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Filter projects</mat-label>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
aria-label="Filter projects"
|
||||
matInput
|
||||
[formControl]="filterProjectInput"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<ul>
|
||||
@for (project of filteredProjects(); track project) {
|
||||
<li class="list-item">
|
||||
<button
|
||||
id="picker-button-{{ project.id }}"
|
||||
class="picker-button mat-body-1"
|
||||
[class.disabled]="!project.isMember"
|
||||
(click)="selectProjectAndClose(forceString(project.id))"
|
||||
>
|
||||
{{ project.name }}
|
||||
</button>
|
||||
<mat-checkbox
|
||||
(change)="toggleProject(forceString(project.id))"
|
||||
[checked]="isSelected(forceString(project.id))"
|
||||
[disabled]="!project.isMember"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="button-group">
|
||||
@if (someProjectsAreSelected()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="navigate(null); expansionPanel.close()"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
}
|
||||
<button mat-flat-button (click)="closePanel()" color="primary">
|
||||
@if (selectedAndAppliedIdsAreEqual() === false) {
|
||||
Apply &
|
||||
}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
@if (appliedProjectIds()?.length) {
|
||||
<button mat-button class="reset-button" (click)="resetProjects()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-accordion>
|
||||
</div>
|
||||
@if (orgSlug(); as orgSlug) {
|
||||
<div class="nav-action-group">
|
||||
@if (accessProjectWrite()) {
|
||||
<a
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="nav-action"
|
||||
[routerLink]="['/', orgSlug, 'settings', 'projects', 'new']"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<ng-container i18n>Add Project</ng-container>
|
||||
</a>
|
||||
}
|
||||
@if (projects.length > 0) {
|
||||
<a
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="nav-action"
|
||||
[routerLink]="
|
||||
singleProjectSlug()
|
||||
? ['/', orgSlug, 'settings', 'projects', singleProjectSlug()]
|
||||
: ['/', orgSlug, 'settings', 'projects']
|
||||
"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
Project Settings
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
@import "variables";
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.no-projects {
|
||||
&__picker {
|
||||
@media screen and (max-width: $small - 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#project-picker {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@media screen and (min-width: ($small - 1)) {
|
||||
max-width: calc(100% - $side-nav-width);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
z-index: 2;
|
||||
height: 48px;
|
||||
color: var(--mat-expansion-header-indicator-color);
|
||||
padding: 0;
|
||||
width: 35px;
|
||||
min-width: 35px;
|
||||
overflow: hidden;
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-expanded + .reset-button {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
mat-accordion {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
height: 48px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
mat-expansion-panel.mat-expansion-panel {
|
||||
flex: 1;
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
mat-expansion-panel::ng-deep {
|
||||
div.mat-expansion-panel-body {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field {
|
||||
display: block;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.mat-expansion-panel-header[aria-disabled="true"] {
|
||||
color: var(--mat-expansion-header-text-color);
|
||||
}
|
||||
|
||||
.mat-expansion-panel-header-title {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
text-align: right;
|
||||
margin-top: 16px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
flex-grow: 1;
|
||||
border: none;
|
||||
margin: 0 15px 0 0; // 0
|
||||
padding: 12px 0; // 0
|
||||
-webkit-text-fill-color: currentColor;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
mat-checkbox ::ng-deep label {
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
$hitbox: -15px;
|
||||
content: "";
|
||||
background: transparent;
|
||||
position: absolute;
|
||||
top: $hitbox;
|
||||
right: $hitbox;
|
||||
bottom: $hitbox;
|
||||
left: $hitbox;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-action-group {
|
||||
margin-left: 10px;
|
||||
|
||||
@media screen and (max-width: $medium - 1) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nav-action:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
margin: 0 0 0 10px;
|
||||
|
||||
@media screen and (max-width: $small - 1) {
|
||||
display: none;
|
||||
|
||||
.no-projects & {
|
||||
display: inline-flex;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-body-1 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ViewChild,
|
||||
OnInit,
|
||||
HostListener,
|
||||
ElementRef,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { MatDatepickerModule } from "@angular/material/datepicker";
|
||||
import { MatExpansionModule } from "@angular/material/expansion";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { MatIconModule } from "@angular/material/icon";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
import { MatCheckboxModule } from "@angular/material/checkbox";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { filter, map, startWith, tap } from "rxjs/operators";
|
||||
import { combineLatest } from "rxjs";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { OrganizationProject } from "src/app/api/projects/projects-api.interfaces";
|
||||
import { Router, ActivatedRoute } from "@angular/router";
|
||||
import { MatExpansionPanel } from "@angular/material/expansion";
|
||||
import { normalizeProjectParams } from "src/app/shared/shared.utils";
|
||||
import { OrganizationsService } from "src/app/api/organizations.service";
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
|
||||
@Component({
|
||||
selector: "gt-project-filter-bar",
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatExpansionModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
],
|
||||
templateUrl: "./project-filter-bar.component.html",
|
||||
styleUrls: ["./project-filter-bar.component.scss"],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectFilterBarComponent implements OnInit {
|
||||
private organizationsService = inject(OrganizationsService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
orgSlug = this.organizationsService.activeOrganizationSlug;
|
||||
/** All projects available */
|
||||
projects = this.organizationsService.activeOrganizationProjects;
|
||||
projects$ = toObservable(
|
||||
this.organizationsService.activeOrganizationProjects,
|
||||
);
|
||||
accessProjectWrite = this.organizationsService.accessProjectWrite
|
||||
|
||||
/** Projects that are selected in this component but not yet applied */
|
||||
selectedProjectIds = signal<number[]>([]);
|
||||
|
||||
/** Observable of selectedProjectIds, intended to separate concerns */
|
||||
selectedProjectIds$ = toObservable(this.selectedProjectIds);
|
||||
|
||||
/** Projects that were previously selected and applied */
|
||||
appliedProjectIds$ = this.route.queryParams.pipe(
|
||||
map((params) => {
|
||||
const normalizedParams = normalizeProjectParams(params.project);
|
||||
this.selectedProjectIds.set(normalizedParams);
|
||||
return normalizedParams;
|
||||
}),
|
||||
);
|
||||
appliedProjectIds = toSignal(this.appliedProjectIds$);
|
||||
|
||||
_appliedProjectIds?: number[];
|
||||
|
||||
/** Use selected projects to generate a string that's displayed in the UI */
|
||||
selectedProjectsString$ = combineLatest([
|
||||
this.projects$,
|
||||
this.selectedProjectIds$,
|
||||
]).pipe(
|
||||
map(([projects, ids]) => {
|
||||
if (projects?.length === 1) {
|
||||
return projects[0].name;
|
||||
}
|
||||
switch (ids.length) {
|
||||
case 0:
|
||||
return "My Projects";
|
||||
case projects?.length:
|
||||
return "All Projects";
|
||||
default:
|
||||
return ids
|
||||
.map(
|
||||
(id) =>
|
||||
projects?.find((project) => id.toString() === project.id)?.name,
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
}),
|
||||
);
|
||||
selectedProjectsString = toSignal(this.selectedProjectsString$);
|
||||
|
||||
selectedAndAppliedIdsAreEqual = computed(() => {
|
||||
const selected = this.selectedProjectIds();
|
||||
const applied = this.appliedProjectIds();
|
||||
return selected?.sort().join(",") === applied?.sort().join(",");
|
||||
});
|
||||
|
||||
//** Yikes delete this please */
|
||||
forceString(value: string | number) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Used to filter project names */
|
||||
filterProjectInput = new FormControl();
|
||||
|
||||
/** Projects that are filtered via the text field form control */
|
||||
filteredProjects$ = combineLatest([
|
||||
this.projects$.pipe(startWith([] as OrganizationProject[])),
|
||||
this.filterProjectInput.valueChanges.pipe(startWith("")),
|
||||
]).pipe(
|
||||
map(([projects, value]) =>
|
||||
projects
|
||||
? projects.filter((project) =>
|
||||
project.name.toLowerCase().includes(value.toLowerCase()),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
filteredProjects = toSignal(this.filteredProjects$);
|
||||
|
||||
someProjectsAreSelected = computed(
|
||||
() => this.appliedProjectIds()?.length !== 0,
|
||||
);
|
||||
|
||||
singleProjectSlug = computed(() => {
|
||||
const projects = this.projects();
|
||||
const ids = this.selectedProjectIds();
|
||||
|
||||
if (ids.length === 1 && projects) {
|
||||
const matchedProject = projects.find(
|
||||
(project) => project.id === ids[0].toString(),
|
||||
);
|
||||
if (matchedProject) {
|
||||
return matchedProject.slug;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
@ViewChild("expansionPanel", { static: false })
|
||||
expansionPanel?: MatExpansionPanel;
|
||||
|
||||
@ViewChild("filterInput", { static: false })
|
||||
filterInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
@HostListener("document:keydown", ["$event"]) onKeydownHandler(
|
||||
event: KeyboardEvent,
|
||||
) {
|
||||
if (this.expansionPanel?.expanded) {
|
||||
if (event.key === "Escape") {
|
||||
this.resetPanel();
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
this.moveDown();
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
this.moveUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the project picker panel (if open) and remove all project filters */
|
||||
resetProjects() {
|
||||
this.closePanel();
|
||||
this.navigate(null);
|
||||
}
|
||||
|
||||
moveDown() {
|
||||
const projectButtons = Array.from(
|
||||
document.querySelectorAll(".picker-button"),
|
||||
) as HTMLElement[];
|
||||
// If the text box is focused, go to the first item
|
||||
if (this.filterInput?.nativeElement.id === document.activeElement?.id) {
|
||||
projectButtons[0]?.focus();
|
||||
} else {
|
||||
const indexOfActive = projectButtons.findIndex(
|
||||
(button) => button.id === document.activeElement?.id,
|
||||
);
|
||||
if (indexOfActive <= projectButtons.length - 2) {
|
||||
// If we're in the list items, go to the next list item
|
||||
projectButtons[indexOfActive + 1].focus();
|
||||
} else {
|
||||
// If we're in the last list item, go to the first item
|
||||
projectButtons[0].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveUp() {
|
||||
const projectButtons = Array.from(
|
||||
document.querySelectorAll(".picker-button"),
|
||||
) as HTMLElement[];
|
||||
const indexOfActive = projectButtons.findIndex(
|
||||
(button) => button.id === document.activeElement?.id,
|
||||
);
|
||||
if (indexOfActive > 0) {
|
||||
// If we're in the list items, go to the previous list item
|
||||
projectButtons[indexOfActive - 1].focus();
|
||||
} else {
|
||||
// If we're in the first list item, go to the first item
|
||||
this.filterInput?.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
navigate(project: string[] | null) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { project: project ? project : null, cursor: null },
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(projectId: string) {
|
||||
return !!this.selectedProjectIds().find(
|
||||
(id) => id.toString() === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
focusPanel() {
|
||||
this.filterInput?.nativeElement.focus();
|
||||
}
|
||||
|
||||
selectProjectAndClose(projectId: string) {
|
||||
this.navigate([projectId.toString()]);
|
||||
this.selectedProjectIds.set([parseInt(projectId)]);
|
||||
this.expansionPanel?.close();
|
||||
}
|
||||
|
||||
toggleProject(projectId: string) {
|
||||
const selectedIds = [...this.selectedProjectIds()];
|
||||
const idMatchIndex = selectedIds.indexOf(parseInt(projectId));
|
||||
if (idMatchIndex > -1) {
|
||||
selectedIds.splice(idMatchIndex, 1);
|
||||
} else {
|
||||
selectedIds.push(parseInt(projectId));
|
||||
}
|
||||
this.selectedProjectIds.set(selectedIds);
|
||||
}
|
||||
|
||||
resetPanel() {
|
||||
if (this._appliedProjectIds !== undefined) {
|
||||
this.selectedProjectIds.set(this._appliedProjectIds);
|
||||
this.expansionPanel?.close();
|
||||
}
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
this.navigate(this.selectedProjectIds().map((id) => id.toString()));
|
||||
this.expansionPanel?.close();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.appliedProjectIds$.subscribe((ids) => {
|
||||
this._appliedProjectIds = ids;
|
||||
});
|
||||
|
||||
this.route.params
|
||||
.pipe(
|
||||
map((params) => params["org-slug"]),
|
||||
filter((orgSlug: string) => orgSlug !== undefined),
|
||||
tap(() => this.expansionPanel?.close()),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ProjectFilterBarComponent } from "./project-filter-bar.component";
|
||||
|
||||
export default {
|
||||
title: "List elements/Project Filter Bar",
|
||||
component: ProjectFilterBarComponent,
|
||||
};
|
||||
|
||||
const sampleProjects: any = [
|
||||
{
|
||||
name: "GlitchTip",
|
||||
slug: "glitchtip",
|
||||
id: 1,
|
||||
platform: "javascript",
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "SwitchGrip",
|
||||
slug: "switchgrip",
|
||||
id: 2,
|
||||
platform: "python-django",
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "PitchFlip",
|
||||
slug: "pitchflip",
|
||||
id: 3,
|
||||
platform: null,
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "StitchStrip",
|
||||
slug: "stitchstrip",
|
||||
id: 4,
|
||||
platform: "javascript",
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "NicheScrip",
|
||||
slug: "nichescrip",
|
||||
id: 5,
|
||||
platform: "python-django",
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "TwitchQuip",
|
||||
slug: "twitchquip",
|
||||
id: 6,
|
||||
platform: null,
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
name: "Wassilievitch Proprietorship",
|
||||
slug: "wassilievitch-proprietorship",
|
||||
id: 7,
|
||||
platform: "javascript",
|
||||
isMember: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const ProjectFilterBar = () => ({
|
||||
props: {
|
||||
projects$: of(sampleProjects),
|
||||
appliedProjectIds: [1, 2, 5],
|
||||
},
|
||||
});
|
||||
|
||||
ProjectFilterBar.story = {
|
||||
parameters: {},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="project-multiselect-formfield"
|
||||
>
|
||||
<mat-select
|
||||
[formControl]="projectsForm"
|
||||
i18n-placeholder
|
||||
placeholder="All projects"
|
||||
multiple
|
||||
(closed)="onSubmit()"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (selectedProjectDisplay(); as selectedProjectDisplay) {
|
||||
<span
|
||||
[matBadge]="'+'+selectedProjectDisplay.additionalProjects"
|
||||
[matBadgeHidden]="!selectedProjectDisplay.additionalProjects"
|
||||
[matBadgeOverlap]="false"
|
||||
class="selected-project-count"
|
||||
>{{ selectedProjectDisplay.firstProjectName }}</span
|
||||
>
|
||||
} @else { All projects }
|
||||
</mat-select-trigger>
|
||||
<mat-option class="project-multiselect__search-field">
|
||||
<ngx-mat-select-search
|
||||
i18n-placeholderLabel
|
||||
placeholderLabel="Search"
|
||||
i18n-noEntriesFoundLabel
|
||||
noEntriesFoundLabel="No projects found"
|
||||
[formControl]="projectSearchForm"
|
||||
[preventHomeEndKeyPropagation]="true"
|
||||
[showToggleAllCheckbox]="true"
|
||||
[toggleAllCheckboxIndeterminate]="someFilteredProjectsSelected()"
|
||||
[toggleAllCheckboxChecked]="allFilteredProjectsSelected()"
|
||||
(toggleAll)="toggleSelectAll($event)"
|
||||
closeIcon="close"
|
||||
/>
|
||||
</mat-option>
|
||||
@for (project of filteredProjects(); track project) {
|
||||
<mat-option [value]="project.id"> {{ project.name }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@@ -0,0 +1,11 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use "variables" as vars;
|
||||
|
||||
|
||||
.project-multiselect-formfield {
|
||||
width: 225px;
|
||||
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
import { MatButtonModule } from "@angular/material/button";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { MatBadge } from "@angular/material/badge";
|
||||
import { MatSelectModule } from "@angular/material/select";
|
||||
import { NgxMatSelectSearchModule } from "ngx-mat-select-search";
|
||||
import { OrganizationsService } from "src/app/api/organizations.service";
|
||||
import { MatIconModule } from "@angular/material/icon";
|
||||
|
||||
@Component({
|
||||
selector: "gt-project-multiselect",
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatBadge,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
NgxMatSelectSearchModule,
|
||||
],
|
||||
templateUrl: "./project-multiselect.html",
|
||||
styleUrl: "./project-multiselect.scss",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectMultiselect {
|
||||
private organizationsService = inject(OrganizationsService);
|
||||
private router = inject(Router);
|
||||
queriedProjects = input<string[]>([]);
|
||||
activeOrgProjects = this.organizationsService.activeOrganizationProjects;
|
||||
projectsForm = new FormControl<string[]>([]);
|
||||
projectSearchForm = new FormControl("");
|
||||
projectsFormChanges = toSignal(this.projectsForm.valueChanges);
|
||||
projectSearchChanges = toSignal(this.projectSearchForm.valueChanges);
|
||||
|
||||
// All active org projects, with currently queried projects listed first
|
||||
sortedProjects = computed(() => {
|
||||
let query = this.queriedProjects();
|
||||
let selectedProjects = this.activeOrgProjects().filter((project) =>
|
||||
query.includes(project.id),
|
||||
);
|
||||
let unselectedProjects = this.activeOrgProjects().filter(
|
||||
(project) => !selectedProjects.includes(project),
|
||||
);
|
||||
return selectedProjects.concat(unselectedProjects);
|
||||
});
|
||||
|
||||
// Projects filtered by name for multiselect search
|
||||
filteredProjects = computed(() => {
|
||||
this.projectSearchChanges();
|
||||
if (!this.sortedProjects()) {
|
||||
return;
|
||||
}
|
||||
let search = this.projectSearchForm.value;
|
||||
if (!search) {
|
||||
return this.sortedProjects();
|
||||
} else {
|
||||
search = search.toLowerCase();
|
||||
}
|
||||
return this.sortedProjects().filter(
|
||||
(project) => project.name.toLowerCase().indexOf(search) > -1,
|
||||
);
|
||||
});
|
||||
allFilteredProjectsSelected = computed(() => {
|
||||
this.projectSearchChanges();
|
||||
this.projectsFormChanges();
|
||||
return this.sortedProjects().every((project) =>
|
||||
this.projectsForm.value?.includes(project.id),
|
||||
);
|
||||
});
|
||||
someFilteredProjectsSelected = computed(() => {
|
||||
this.projectSearchChanges();
|
||||
this.projectsFormChanges();
|
||||
return (
|
||||
this.sortedProjects().some((project) =>
|
||||
this.projectsForm.value?.includes(project.id),
|
||||
) && this.projectsForm.value?.length! < this.sortedProjects().length
|
||||
);
|
||||
});
|
||||
selectedProjectDisplay = computed(() => {
|
||||
this.queriedProjects();
|
||||
this.projectsFormChanges();
|
||||
if (this.projectsForm.value?.length) {
|
||||
const numProjectsSelected = this.projectsForm.value.length;
|
||||
if (
|
||||
numProjectsSelected > 1 &&
|
||||
numProjectsSelected === this.activeOrgProjects().length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const firstProjectName = this.activeOrgProjects().find(
|
||||
(project) => project.id === this.projectsForm.value![0],
|
||||
)?.name;
|
||||
const additionalProjects = numProjectsSelected - 1;
|
||||
return { firstProjectName, additionalProjects };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.projectsForm.setValue(this.queriedProjects());
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelectAll(selectAll: boolean) {
|
||||
let filteredProjects = this.filteredProjects();
|
||||
if (selectAll) {
|
||||
if (filteredProjects) {
|
||||
this.projectsForm.setValue(
|
||||
filteredProjects.map((project) => project.id),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.projectsForm.setValue([]);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
let projects = this.projectsForm.value;
|
||||
this.projectSearchForm.reset();
|
||||
this.router.navigate([], {
|
||||
queryParams: { cursor: null, project: projects ? projects : null },
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<div class="time-range-form-container">
|
||||
<form
|
||||
class="custom-time-range-form"
|
||||
[formGroup]="customTimeRange"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<div class="input-group-header body-text">
|
||||
<span class="body-text-strong"> {{ dateRangeHeader() }} </span>
|
||||
<span>{{ dateRangeCtrl.value?.start | date: "shortDate" }}</span>
|
||||
@if (dateRangeCtrl.value?.end; as endDate) {
|
||||
<span> - {{ endDate | date: "shortDate" }}</span>
|
||||
}
|
||||
</div>
|
||||
<mat-calendar
|
||||
[selected]="dateRangeCtrl.value"
|
||||
(selectedChange)="onCalendarChange($event)"
|
||||
></mat-calendar>
|
||||
<div class="input-group-header body-text-strong" i18n>Time range:</div>
|
||||
<div class="time-pickers">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Start time</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="startTime"
|
||||
[matTimepicker]="startTimepicker"
|
||||
/>
|
||||
<mat-timepicker-toggle matIconSuffix [for]="startTimepicker" />
|
||||
<mat-timepicker #startTimepicker />
|
||||
@if (this.startTimeCtrl.errors?.['matTimepickerParse']) {
|
||||
<mat-error>Pick a valid time</mat-error>
|
||||
} @if (this.startTimeCtrl.errors?.['required']) {
|
||||
<mat-error>Pick a time</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End time</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="endTimeCtrl"
|
||||
[matTimepicker]="endTimepicker"
|
||||
/>
|
||||
<mat-timepicker-toggle matIconSuffix [for]="endTimepicker" />
|
||||
<mat-timepicker #endTimepicker />
|
||||
@if (this.endTimeCtrl.errors?.['matTimepickerParse']) {
|
||||
<mat-error>Pick a valid time</mat-error>
|
||||
} @if (this.startTimeCtrl.errors?.['required']) {
|
||||
<mat-error>Pick a time</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button matButton i18n type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-flat-button type="submit" i18n>Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
@use "variables" as vars;
|
||||
|
||||
.time-range-form-container {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
|
||||
@media screen and (min-width: vars.$Xsmall - 1) {
|
||||
width: 325px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-range-preview {
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.custom-time-range-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group-header {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.time-pickers,
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-pickers {
|
||||
width: 100%;
|
||||
gap: 25px;
|
||||
|
||||
mat-form-field {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-calendar {
|
||||
width: 100%;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
margin-bottom: 15px;
|
||||
border-radius: vars.$shape-radius;
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
MatNativeDateModule,
|
||||
provideNativeDateAdapter,
|
||||
} from "@angular/material/core";
|
||||
import { MatButton } from "@angular/material/button";
|
||||
import {
|
||||
DateRange,
|
||||
MatCalendar,
|
||||
MatDatepickerModule,
|
||||
} from "@angular/material/datepicker";
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
||||
import { MatTimepickerModule } from "@angular/material/timepicker";
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import {
|
||||
DefaultMatCalendarRangeStrategy,
|
||||
MatRangeDateSelectionModel,
|
||||
} from "@angular/material/datepicker";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { SettingsService } from "src/app/api/settings.service";
|
||||
|
||||
export interface CustomTimeRangeOutput {
|
||||
dateRange: DateRange<Date>;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "gt-custom-time-range-form",
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButton,
|
||||
MatCalendar,
|
||||
MatTimepickerModule,
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatNativeDateModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
providers: [
|
||||
provideNativeDateAdapter(),
|
||||
DefaultMatCalendarRangeStrategy,
|
||||
MatRangeDateSelectionModel,
|
||||
],
|
||||
templateUrl: "./custom-time-range-form.html",
|
||||
styleUrl: "./custom-time-range-form.scss",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CustomTimeRangeForm {
|
||||
readonly dialogRef = inject(
|
||||
MatDialogRef<CustomTimeRangeForm, CustomTimeRangeOutput>,
|
||||
);
|
||||
readonly queriedDateRange: DateRange<Date> | undefined =
|
||||
inject(MAT_DIALOG_DATA);
|
||||
protected settingsService = inject(SettingsService);
|
||||
|
||||
generateDefaultTime(isEndTime: boolean = false) {
|
||||
const date = new Date();
|
||||
if (isEndTime) {
|
||||
date.setHours(23, 59, 59, 999);
|
||||
} else {
|
||||
date.setHours(0, 0, 0, 0);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
getInputOrDefaultDateRange() {
|
||||
if (this.queriedDateRange?.start || this.queriedDateRange?.end) {
|
||||
return this.queriedDateRange;
|
||||
}
|
||||
return new DateRange<Date>(new Date(), new Date());
|
||||
}
|
||||
dateRangeCtrl = new FormControl<DateRange<Date>>(
|
||||
this.getInputOrDefaultDateRange(),
|
||||
);
|
||||
startTimeCtrl = new FormControl<Date>(
|
||||
this.queriedDateRange?.start ?? this.generateDefaultTime(),
|
||||
Validators.required,
|
||||
);
|
||||
endTimeCtrl = new FormControl<Date>(
|
||||
this.queriedDateRange?.end ?? this.generateDefaultTime(true),
|
||||
Validators.required,
|
||||
);
|
||||
customTimeRange = new FormGroup({
|
||||
dateRange: this.dateRangeCtrl,
|
||||
startTime: this.startTimeCtrl,
|
||||
endTime: this.endTimeCtrl,
|
||||
});
|
||||
|
||||
serverTimeZone = this.settingsService.serverTimeZone;
|
||||
dateRangeHeader = computed(() => {
|
||||
let header = $localize`Date range`;
|
||||
if (this.serverTimeZone()) {
|
||||
header += ` (${this.serverTimeZone()})`;
|
||||
}
|
||||
return header + ":";
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly selectionModel: MatRangeDateSelectionModel<Date>,
|
||||
private readonly selectionStrategy: DefaultMatCalendarRangeStrategy<Date>,
|
||||
) {}
|
||||
|
||||
onCalendarChange(date: Date) {
|
||||
const selection = this.selectionModel.selection,
|
||||
newSelection = this.selectionStrategy.selectionFinished(date, selection);
|
||||
|
||||
this.selectionModel.updateSelection(newSelection, this);
|
||||
const newDateRange = new DateRange<Date>(
|
||||
newSelection.start,
|
||||
newSelection.end,
|
||||
);
|
||||
this.dateRangeCtrl.setValue(newDateRange);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.customTimeRange.valid) {
|
||||
this.dialogRef.close({
|
||||
dateRange: this.dateRangeCtrl.value,
|
||||
startTime: this.startTimeCtrl.value,
|
||||
endTime: this.endTimeCtrl.value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
[class]="{'time-range-select-formfield': true, 'wide': extendSelectSize()}"
|
||||
>
|
||||
<mat-select
|
||||
[formControl]="presetRangeForm"
|
||||
i18n-placeholder
|
||||
placeholder="Time range"
|
||||
(selectionChange)="onPresetSelect($event)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-select-trigger> {{ queriedTimerangeDisplay() }} </mat-select-trigger>
|
||||
@for (preset of timeRangePresets; track preset.value) {
|
||||
<mat-option [value]="preset.value">{{preset.display}}</mat-option>
|
||||
}
|
||||
<mat-option
|
||||
value="custom"
|
||||
(onSelectionChange)="onCustomRangeSelect($event)"
|
||||
i18n
|
||||
>Custom range</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@@ -0,0 +1,32 @@
|
||||
@use "variables" as vars;
|
||||
|
||||
.custom-range-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-range-select-formfield {
|
||||
width: 200px;
|
||||
|
||||
&.wide {
|
||||
// TODO: Find a cleaner way to manage the variable sizes here
|
||||
@media screen and (min-width: vars.$small) {
|
||||
width: 325px;
|
||||
@media screen and (max-width: vars.$large - 199) {
|
||||
width: 200px;
|
||||
}
|
||||
@media screen and (max-width: vars.$tablet - 1) {
|
||||
max-width: 250px;
|
||||
}
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: vars.$small - 1) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { CommonModule, formatDate } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { MatOptionSelectionChange } from "@angular/material/core";
|
||||
import { MatInputModule } from "@angular/material/input";
|
||||
import { MatFormFieldModule } from "@angular/material/form-field";
|
||||
import { MatDialog } from "@angular/material/dialog";
|
||||
import { MatSelectModule, MatSelectChange } from "@angular/material/select";
|
||||
import {
|
||||
CustomTimeRangeForm,
|
||||
CustomTimeRangeOutput,
|
||||
} from "./custom-timerange-form/custom-time-range-form";
|
||||
import { DateRange } from "@angular/material/datepicker";
|
||||
|
||||
const NO_FILTER_TEXT = $localize`All times`;
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "gt-time-range-select",
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: "./time-range-select.html",
|
||||
styleUrl: "./time-range-select.scss",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TimeRangeSelect {
|
||||
protected router = inject(Router);
|
||||
dialog = inject(MatDialog);
|
||||
timeRangePresets = [
|
||||
{ display: NO_FILTER_TEXT, value: "" },
|
||||
{ display: $localize`Last hour`, value: "now-1h" },
|
||||
{ display: $localize`Last 24 hours`, value: "now-1d" },
|
||||
{ display: $localize`Last 7 days`, value: "now-7d" },
|
||||
{ display: $localize`Last 14 days`, value: "now-14d" },
|
||||
{ display: $localize`Last 30 days`, value: "now-30d" },
|
||||
];
|
||||
queriedTimeRangeStart = input<string | undefined>();
|
||||
queriedTimeRangeEnd = input<string | undefined>();
|
||||
presetRangeForm = new FormControl<string | null>("");
|
||||
queriedPresetTimeRange = computed(() => {
|
||||
const presetValues = this.timeRangePresets.map((preset) => preset.value);
|
||||
const start = this.queriedTimeRangeStart();
|
||||
const end = this.queriedTimeRangeEnd();
|
||||
if (start && presetValues.includes(start) && (!end || end === "now")) {
|
||||
return start;
|
||||
}
|
||||
if (!start && !end) {
|
||||
return "";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Return a converted date if possible, otherwise just return
|
||||
// the string
|
||||
formatDateStringDisplay(dateString: string | undefined) {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
const convertedDate = new Date(dateString.replace("Z", ""));
|
||||
if (!isNaN(convertedDate.getTime())) {
|
||||
return formatDate(convertedDate, "MM/dd h:mm a", "en-us");
|
||||
} else {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
queriedTimerangeDisplay = computed(() => {
|
||||
const queriedPreset = this.queriedPresetTimeRange();
|
||||
if (queriedPreset) {
|
||||
return this.timeRangePresets.find(
|
||||
(preset) => preset.value === queriedPreset,
|
||||
)?.display;
|
||||
}
|
||||
const formattedStart = this.formatDateStringDisplay(
|
||||
this.queriedTimeRangeStart(),
|
||||
);
|
||||
const formattedEnd = this.formatDateStringDisplay(
|
||||
this.queriedTimeRangeEnd(),
|
||||
);
|
||||
|
||||
if (formattedStart && formattedEnd) {
|
||||
return formattedStart + " - " + formattedEnd;
|
||||
}
|
||||
|
||||
if (formattedStart && !formattedEnd) {
|
||||
return formattedStart + " - now";
|
||||
}
|
||||
|
||||
if (!formattedStart && formattedEnd) {
|
||||
return `Until ${formattedEnd}`;
|
||||
}
|
||||
|
||||
return NO_FILTER_TEXT;
|
||||
});
|
||||
|
||||
extendSelectSize = computed(
|
||||
() =>
|
||||
this.queriedTimerangeDisplay() &&
|
||||
this.queriedTimerangeDisplay()!.length > 14,
|
||||
);
|
||||
|
||||
// For passing already-queried custom date range to
|
||||
// the custom timerange dialog.
|
||||
// If query includes valid, non-relative date times,
|
||||
// returned date range will include them. Otherwise,
|
||||
// they will be replaced with null values in the
|
||||
// returned date range
|
||||
queriedTimerangeParsed = computed(() => {
|
||||
const start = this.queriedTimeRangeStart()?.replace("Z", "");
|
||||
let startParsed: Date | null = new Date(start as any);
|
||||
startParsed = isNaN(startParsed.getDate()) ? null : startParsed;
|
||||
const end = this.queriedTimeRangeEnd()?.replace("Z", "");
|
||||
let endParsed: Date | null = new Date(end as any);
|
||||
endParsed = isNaN(endParsed.getDate()) ? null : endParsed;
|
||||
return new DateRange<Date>(startParsed, endParsed);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const queriedPreset = this.queriedPresetTimeRange();
|
||||
if (queriedPreset !== null) {
|
||||
this.presetRangeForm.setValue(queriedPreset);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The custom time range form tracks dates and start/end times separately,
|
||||
// so when we get them here we need to combine them
|
||||
private mergeDateTime(date: Date | null, time: Date | null) {
|
||||
if (date && time) {
|
||||
date.setHours(time.getHours());
|
||||
date.setMinutes(time.getMinutes());
|
||||
date.setSeconds(time.getSeconds());
|
||||
date.setMilliseconds(time.getMilliseconds());
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private convertToZTime(date: Date) {
|
||||
return formatDate(date, "yyyy-MM-ddTHH:mm:ss.SSS", "en-US") + "Z";
|
||||
}
|
||||
|
||||
openCustomTimerangeForm() {
|
||||
const dialogRef = this.dialog.open<
|
||||
CustomTimeRangeForm,
|
||||
unknown,
|
||||
CustomTimeRangeOutput
|
||||
>(CustomTimeRangeForm, { data: this.queriedTimerangeParsed() });
|
||||
dialogRef.afterClosed().subscribe((output) => {
|
||||
if (output) {
|
||||
const start = this.mergeDateTime(
|
||||
output.dateRange.start,
|
||||
output.startTime,
|
||||
);
|
||||
const end = this.mergeDateTime(output.dateRange.end, output.endTime);
|
||||
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
start: start ? this.convertToZTime(start) : undefined,
|
||||
end: end ? this.convertToZTime(end) : undefined,
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPresetSelect(event: MatSelectChange<string | undefined>) {
|
||||
// Handled by onCustomRangeSelect
|
||||
if (event.value === "custom") {
|
||||
return;
|
||||
}
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
cursor: null,
|
||||
start: this.presetRangeForm.value || undefined,
|
||||
end: undefined,
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
});
|
||||
}
|
||||
|
||||
onCustomRangeSelect(event: MatOptionSelectionChange) {
|
||||
if (event.isUserInput) {
|
||||
this.openCustomTimerangeForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<gt-main-nav>
|
||||
<gt-rate-limit-banner></gt-rate-limit-banner>
|
||||
<router-outlet></router-outlet>
|
||||
</gt-main-nav>
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Component, OnInit, inject } from "@angular/core";
|
||||
import { RouterOutlet } from "@angular/router";
|
||||
import { MainNavComponent } from "./main-nav/main-nav/main-nav.component";
|
||||
import { RateLimitBannerComponent } from "./rate-limit-banner/rate-limit-banner.component";
|
||||
import { UserService } from "./api/user/user.service";
|
||||
|
||||
@Component({
|
||||
selector: "gt-logged-in",
|
||||
templateUrl: "./logged-in.component.html",
|
||||
imports: [MainNavComponent, RouterOutlet, RateLimitBannerComponent],
|
||||
imports: [MainNavComponent, RouterOutlet],
|
||||
})
|
||||
export class LoggedInComponent implements OnInit {
|
||||
private userService = inject(UserService);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
<button id="submit" mat-flat-button color="primary" i18n>Login</button>
|
||||
<!-- <mat-checkbox class="mat-caption" formControlName="remember">Don't prompt again on this device</mat-checkbox> -->
|
||||
<!-- <mat-checkbox class="caption-text" formControlName="remember">Don't prompt again on this device</mat-checkbox> -->
|
||||
</form>
|
||||
|
||||
@if (hasWebAuthn!()) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<!-- <mat-checkbox class="remember-me-checkbox mat-caption" (change)="toggleRemember($event.checked)">Don't prompt again on
|
||||
<!-- <mat-checkbox class="remember-me-checkbox caption-text" (change)="toggleRemember($event.checked)">Don't prompt again on
|
||||
this device</mat-checkbox> -->
|
||||
<div>
|
||||
<a [routerLink]="[]" (click)="switchMethod()" i18n
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
@if (socialApps(); as socialApps) {
|
||||
@if (socialApps.length) {
|
||||
<div class="or">
|
||||
<span class="mat-body-strong" i18n>or</span>
|
||||
<span class="body-text-strong" i18n>or</span>
|
||||
</div>
|
||||
}
|
||||
<div class="o-auth">
|
||||
@@ -86,17 +86,17 @@
|
||||
</div>
|
||||
}
|
||||
<div class="sign-up">
|
||||
@if (enableUserRegistration()) {
|
||||
<p class="mat-body-1">
|
||||
@if (enableUserRegistration() || enableSocialAppsUserRegistration()) {
|
||||
<p class="body-text">
|
||||
<ng-container i18n>New to GlitchTip?</ng-container>
|
||||
<a routerLink="../register" i18n>Sign Up</a>
|
||||
<a class="auth-link" routerLink="../register" i18n>Sign Up</a>
|
||||
</p>
|
||||
}
|
||||
<p class="mat-body-1">
|
||||
<a routerLink="/reset-password" i18n>Reset Password</a>
|
||||
<p class="body-text">
|
||||
<a class="auth-link" routerLink="/reset-password" i18n>Reset Password</a>
|
||||
</p>
|
||||
|
||||
<p markdown>
|
||||
<p class="alternative-instance" markdown>
|
||||
{{ instanceName() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ export class LoginComponent
|
||||
|
||||
socialApps = this.settings.socialApps;
|
||||
enableUserRegistration = this.settings.enableUserRegistration;
|
||||
enableSocialAppsUserRegistration = this.settings.enableSocialAppsUserRegistration;
|
||||
|
||||
constructor() {
|
||||
const service = inject(LoginService);
|
||||
|
||||
@@ -18,237 +18,152 @@
|
||||
</mat-toolbar>
|
||||
@for (org of organizations(); track org.id) {}
|
||||
@if (contextLoaded() && organizations().length === 0 && canCreateOrg()) {
|
||||
<a
|
||||
mat-button
|
||||
id="create-new-link"
|
||||
routerLink="/organizations/new"
|
||||
class="org-create no-org-create"
|
||||
i18n
|
||||
>Create New Organization</a
|
||||
>
|
||||
} @else {
|
||||
<button
|
||||
mat-button
|
||||
id="org-dropdown"
|
||||
[disabled]="contextLoaded() === false"
|
||||
[matMenuTriggerFor]="menu"
|
||||
class="org-dropdown"
|
||||
>
|
||||
<div class="label-container">
|
||||
<span class="overflow">
|
||||
{{ activeOrganization()?.name }}
|
||||
</span>
|
||||
<span class="down-caret">▼</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<mat-menu #menu="matMenu">
|
||||
@for (organization of organizations(); track organization.id) {
|
||||
<button (click)="setOrganization(organization.slug)" mat-menu-item>
|
||||
{{ organization.name }}
|
||||
</button>
|
||||
}
|
||||
<mat-divider></mat-divider>
|
||||
@if (canCreateOrg()) {
|
||||
<div class="org-create-button-container">
|
||||
<a
|
||||
mat-menu-item
|
||||
matButton="outlined"
|
||||
id="create-new-link"
|
||||
routerLink="/organizations/new"
|
||||
class="org-create-button"
|
||||
i18n
|
||||
>Create New Organization</a
|
||||
>Create Organization<mat-icon iconPositionEnd>add</mat-icon></a
|
||||
>
|
||||
} @else {
|
||||
<span
|
||||
mat-menu-item
|
||||
disabled
|
||||
matTooltip="Organization creation is currently disabled"
|
||||
matTooltipPosition="below"
|
||||
</div>
|
||||
} @else {
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="org-dropdown-container"
|
||||
>
|
||||
<mat-select
|
||||
#orgSelect
|
||||
data-cy="orgSelect"
|
||||
[value]="activeOrganizationSlug()"
|
||||
formControlName="orgSelectControl"
|
||||
(selectionChange)="onOrgSelectChange($event, orgSelect)"
|
||||
hideSingleSelectionIndicator
|
||||
canSelectNullableOptions
|
||||
>
|
||||
Create New Organization
|
||||
</span>
|
||||
}
|
||||
</mat-menu>
|
||||
<mat-nav-list class="nav-link-list">
|
||||
<a
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[activeOrganizationSlug(), 'issues']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Issues</a
|
||||
@for (
|
||||
organization of organizations();
|
||||
track organization.id;
|
||||
let last = $last
|
||||
) {
|
||||
<mat-option [value]="organization.slug">
|
||||
{{ organization.name }}
|
||||
</mat-option>
|
||||
@if (last) {
|
||||
<mat-divider class="dropdown-divider"></mat-divider>
|
||||
}
|
||||
}
|
||||
@if (canCreateOrg()) {
|
||||
<mat-option data-cy="createNewLink" i18n
|
||||
>Create organization</mat-option
|
||||
>
|
||||
} @else {
|
||||
<mat-option
|
||||
disabled
|
||||
matTooltip="Organization creation is currently disabled"
|
||||
matTooltipPosition="below"
|
||||
>
|
||||
Create organization
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-nav-list>
|
||||
<mat-tree
|
||||
#tree
|
||||
[dataSource]="menuData"
|
||||
[childrenAccessor]="childrenAccessor"
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[activeOrganizationSlug(), 'uptime-monitors']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Uptime Monitors</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[
|
||||
activeOrganizationSlug(),
|
||||
'performance',
|
||||
'transaction-groups',
|
||||
]"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Performance</a
|
||||
>
|
||||
<mat-divider class="org-details-divider"></mat-divider>
|
||||
<a
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[activeOrganizationSlug(), 'projects']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Projects</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[activeOrganizationSlug(), 'releases']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Releases</a
|
||||
>
|
||||
<a
|
||||
data-test-settings
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="[activeOrganizationSlug(), 'settings']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Settings</a
|
||||
>
|
||||
<a
|
||||
class="sub-items"
|
||||
mat-list-item
|
||||
[disabled]="!activeOrganization()"
|
||||
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'projects']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Projects</a
|
||||
>
|
||||
@if (billingEnabled()) {
|
||||
<a
|
||||
class="sub-items"
|
||||
mat-list-item
|
||||
[routerLink]="[
|
||||
'/',
|
||||
activeOrganizationSlug(),
|
||||
'settings',
|
||||
'subscription',
|
||||
]"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Subscription</a
|
||||
<!-- Default template for navigable items -->
|
||||
<mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding>
|
||||
@if (activeOrganization() || !node.requiresActiveOrg) {
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="getRouteWithOrgSlug(node.route)"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{
|
||||
exact: node.useExactRoute ?? false,
|
||||
}"
|
||||
#issuesLink="routerLinkActive"
|
||||
[activated]="issuesLink.isActive"
|
||||
>{{ node.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<mat-list-item disabled>
|
||||
{{ node.name }}
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-tree-node>
|
||||
|
||||
<!-- Hide subscription route when billing is not enabled -->
|
||||
<mat-tree-node
|
||||
*matTreeNodeDef="let node; when: hideBilling"
|
||||
matTreeNodePadding
|
||||
style="display: none"
|
||||
/>
|
||||
|
||||
<!-- Template for expandable nav nodes -->
|
||||
<mat-tree-node
|
||||
isExpandable
|
||||
*matTreeNodeDef="let node; when: hasChild"
|
||||
matTreeNodePadding
|
||||
[isDisabled]="!activeOrganization() && node.requiresActiveOrg"
|
||||
[cdkTreeNodeTypeaheadLabel]="node.name"
|
||||
>
|
||||
}
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'teams']"
|
||||
routerLinkActive="active-route"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Teams</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/', activeOrganizationSlug(), 'settings', 'members']"
|
||||
routerLinkActive="active-route"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Members</a
|
||||
>
|
||||
<mat-divider class="org-details-divider"></mat-divider>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/profile']"
|
||||
routerLinkActive="active-route"
|
||||
i18n
|
||||
>Profile</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/profile']"
|
||||
routerLinkActive="active-route"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Account</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/profile', 'multi-factor-auth']"
|
||||
routerLinkActive="active-route"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Multi-factor Authentication</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/profile', 'notifications']"
|
||||
routerLinkActive="active-route"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Notifications</a
|
||||
>
|
||||
<a
|
||||
mat-list-item
|
||||
[routerLink]="['/profile', 'auth-tokens']"
|
||||
routerLinkActive="active-route"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
class="sub-items"
|
||||
i18n
|
||||
>Auth Tokens</a
|
||||
>
|
||||
<button mat-menu-item (click)="this.logout()" i18n>Log Out</button>
|
||||
<mat-list-item
|
||||
matTreeNodeToggle
|
||||
[disabled]="!activeOrganization() && node.requiresActiveOrg"
|
||||
>
|
||||
<span matListItemTitle>{{ node.name }}</span>
|
||||
<div matListItemMeta>
|
||||
<mat-icon>
|
||||
{{
|
||||
tree.isExpanded(node) ? "arrow_drop_down" : "arrow_right"
|
||||
}}
|
||||
</mat-icon>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-tree-node>
|
||||
</mat-tree>
|
||||
<a mat-list-item (click)="this.logout()" i18n>Log Out</a>
|
||||
</mat-nav-list>
|
||||
@if (paidForGlitchTip() === false) {
|
||||
<mat-card class="nagware">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<h3 i18n>Support GlitchTip</h3>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ul class="mat-body-1">
|
||||
<li i18n
|
||||
<mat-divider class="support-cta-divider"></mat-divider>
|
||||
<div class="support-cta-container">
|
||||
<div class="body-text-strong" il8n>Support GlitchTip</div>
|
||||
<ul class="support-cta-list body-text">
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://liberapay.com/GlitchTip/donate"
|
||||
il8n
|
||||
>Donate
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://liberapay.com/GlitchTip/donate"
|
||||
>
|
||||
via Liberapay
|
||||
</a>
|
||||
</li>
|
||||
<li i18n>
|
||||
Switch to
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://app.glitchtip.com/register?utm_medium=website&utm_source=glitchtip&utm_campaign=selfhost_nagware"
|
||||
>Hosted GlitchTip</a
|
||||
>
|
||||
</li>
|
||||
<li i18n>
|
||||
Purchase support:
|
||||
<a
|
||||
href="mailto:sales@glitchtip.com?subject=Purchase support inquiry"
|
||||
>sales@glitchtip.com</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://app.glitchtip.com/register?utm_medium=website&utm_source=glitchtip&utm_campaign=selfhost_nagware"
|
||||
il8n
|
||||
>Switch to hosted</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:sales@glitchtip.com?subject=Purchase support inquiry"
|
||||
il8n
|
||||
>Purchase support</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<div class="mat-typography version">
|
||||
<div class="caption-text version">
|
||||
<a [routerLink]="['/system-info']" routerLinkActive="active-route" i18n
|
||||
>GlitchTip {{ version() }}</a
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "@angular/material" as mat;
|
||||
@import "variables";
|
||||
@use "variables";
|
||||
|
||||
// This make the app cover the whole screen at all times
|
||||
mat-sidenav-container {
|
||||
@@ -11,10 +11,23 @@ mat-sidenav-container {
|
||||
}
|
||||
|
||||
mat-sidenav {
|
||||
width: $side-nav-width;
|
||||
width: variables.$side-nav-width;
|
||||
}
|
||||
|
||||
mat-nav-list {
|
||||
padding: 10px;
|
||||
|
||||
// TODO: Find a more reliable way to change icon color
|
||||
.mdc-list-item--disabled {
|
||||
mat-icon {
|
||||
opacity: .25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-toolbar {
|
||||
height: fit-content;
|
||||
padding-top: 16px;
|
||||
background-color: transparent;
|
||||
|
||||
.main-logo {
|
||||
@@ -23,102 +36,30 @@ mat-sidenav {
|
||||
}
|
||||
}
|
||||
|
||||
.sub-items {
|
||||
display: none;
|
||||
|
||||
@media (max-width: $tablet - 1) {
|
||||
display: flex;
|
||||
margin-left: 24px;
|
||||
max-width: calc(100% - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-height: 40px;
|
||||
line-height: 1.11111;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.down-caret {
|
||||
transform: scale(1, 0.5);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.org-dropdown,
|
||||
.no-org-create {
|
||||
.org-create-button-container,
|
||||
.org-dropdown-container {
|
||||
width: 100%;
|
||||
padding: 25px 15px 0px 15px;
|
||||
}
|
||||
|
||||
.org-create-button {
|
||||
height: 48px;
|
||||
border-radius: 0px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
}
|
||||
.support-cta-divider {
|
||||
border-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.org-dropdown {
|
||||
::ng-deep .mdc-button__label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
display: inline-flex;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.support-cta-container {
|
||||
margin: 15px 0 0 26px;
|
||||
}
|
||||
|
||||
.no-org-create {
|
||||
font-weight: 500;
|
||||
}
|
||||
.support-cta-list {
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
margin-top: 8px;
|
||||
|
||||
.org-details-divider {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.nav-link-list {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
color: mat.m2-get-color-from-palette(mat.$m2-gray-palette, 700);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.nagware {
|
||||
margin: 10px;
|
||||
font-size: 0.8em;
|
||||
|
||||
h3 {
|
||||
font-size: 0.8em;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user