Add people pages

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-31 18:04:54 +02:00
committed by Sacha Al Himdani
parent 6862841459
commit b94b58e2de
30 changed files with 2589 additions and 213 deletions

View File

@@ -0,0 +1,49 @@
import { Button, IconPlusLarge, IconTrashCan, Input, Label } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useFieldArray } from "react-hook-form";
import type { Control } from "react-hook-form";
import type { UseFormRegister } from "react-hook-form";
type Props = {
control: Control<any>;
register: UseFormRegister<any>;
};
/**
* A field to handle multiple emails
*/
export function EmailsField({ control, register }: Props) {
const { __ } = useTranslate();
const { fields, append, remove } = useFieldArray({
name: "additionalEmailAddresses",
control,
});
return (
<fieldset className="space-y-2">
{fields.length > 0 && <Label>{__("Additional emails")}</Label>}
{fields.map((field, index) => (
<div key={field.id} className="flex items-stretch">
<Input
className="w-full"
{...register(`additionalEmailAddresses.${index}`)}
type="email"
/>
<Button
icon={IconTrashCan}
variant="tertiary"
onClick={() => remove(index)}
/>
</div>
))}
<Button
variant="tertiary"
type="button"
icon={IconPlusLarge}
onClick={() => append("")}
>
{__("Add email")}
</Button>
</fieldset>
);
}

View File

@@ -1,7 +1,20 @@
import { graphql } from "relay-runtime";
import type { PeopleGraphQuery } from "./__generated__/PeopleGraphQuery.graphql";
import { useLazyLoadQuery } from "react-relay";
import { useMemo } from "react";
import {
useLazyLoadQuery,
useMutation,
usePreloadedQuery,
useRefetchableFragment,
type PreloadedQuery,
} from "react-relay";
import { useMemo, useTransition } from "react";
import type { PeopleGraphPaginatedQuery } from "./__generated__/PeopleGraphPaginatedQuery.graphql";
import type { PeopleGraphPaginatedFragment$key } from "./__generated__/PeopleGraphPaginatedFragment.graphql";
import { useConfirm } from "@probo/ui";
import type { PeopleGraphDeleteMutation } from "./__generated__/PeopleGraphDeleteMutation.graphql";
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import type { PeopleGraphNodeQuery$data } from "./__generated__/PeopleGraphNodeQuery.graphql";
const peopleQuery = graphql`
query PeopleGraphQuery($organizationId: ID!) {
@@ -21,6 +34,9 @@ const peopleQuery = graphql`
}
`;
/**
* Return a list of people (used for people selectors)
*/
export function usePeople(organizationId: string) {
const data = useLazyLoadQuery<PeopleGraphQuery>(peopleQuery, {
organizationId: organizationId,
@@ -29,3 +45,139 @@ export function usePeople(organizationId: string) {
return data.organization?.peoples?.edges.map((edge) => edge.node) ?? [];
}, [data]);
}
export const paginatedPeopleQuery = graphql`
query PeopleGraphPaginatedQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
id
...PeopleGraphPaginatedFragment
}
}
}
`;
const paginatedPeopleFragment = graphql`
fragment PeopleGraphPaginatedFragment on Organization
@refetchable(queryName: "PeopleListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "PeopleOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
peoples(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "PeopleGraphPaginatedQuery_peoples") {
__id
edges {
node {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
}
`;
export function usePeopleQuery(
queryRef: PreloadedQuery<PeopleGraphPaginatedQuery>
) {
const data = usePreloadedQuery(paginatedPeopleQuery, queryRef);
const [dataFragment, refetch] = useRefetchableFragment(
paginatedPeopleFragment,
data.organization as PeopleGraphPaginatedFragment$key
);
const people = dataFragment?.peoples?.edges.map((edge) => edge.node);
return {
people,
refetch,
connectionId: dataFragment.peoples.__id,
};
}
export const deletePeopleMutation = graphql`
mutation PeopleGraphDeleteMutation(
$input: DeletePeopleInput!
$connections: [ID!]!
) {
deletePeople(input: $input) {
deletedPeopleId @deleteEdge(connections: $connections)
}
}
`;
export const PeopleConnectionKey = "PeopleGraphPaginatedQuery_peoples";
export const useDeletePeople = (
people: { id?: string; fullName?: string },
connectionId: string
) => {
const [mutate] = useMutation<PeopleGraphDeleteMutation>(deletePeopleMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!people.id || !people.fullName) {
return alert(__("Failed to delete people: missing id or fullName"));
}
confirm(
() =>
new Promise((resolve) => {
mutate({
variables: {
input: {
peopleId: people.id!,
},
connections: [connectionId],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete "%s". This action cannot be undone.'
),
people.fullName
),
}
);
};
};
export const peopleNodeQuery = graphql`
query PeopleGraphNodeQuery($peopleId: ID!) {
node(id: $peopleId) {
... on People {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
`;
export const updatePeopleMutation = graphql`
mutation PeopleGraphUpdateMutation($input: UpdatePeopleInput!) {
updatePeople(input: $input) {
people {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
`;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<242c546027e5b7c8410fca920a4dfa3c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeletePeopleInput = {
peopleId: string;
};
export type PeopleGraphDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeletePeopleInput;
};
export type PeopleGraphDeleteMutation$data = {
readonly deletePeople: {
readonly deletedPeopleId: string;
};
};
export type PeopleGraphDeleteMutation = {
response: PeopleGraphDeleteMutation$data;
variables: PeopleGraphDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedPeopleId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "PeopleGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeletePeoplePayload",
"kind": "LinkedField",
"name": "deletePeople",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "PeopleGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeletePeoplePayload",
"kind": "LinkedField",
"name": "deletePeople",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedPeopleId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "9e0f5fd398468199333312505f258bb2",
"id": null,
"metadata": {},
"name": "PeopleGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation PeopleGraphDeleteMutation(\n $input: DeletePeopleInput!\n) {\n deletePeople(input: $input) {\n deletedPeopleId\n }\n}\n"
}
};
})();
(node as any).hash = "611aa91bbb039c4e5800838f5db7985c";
export default node;

View File

@@ -0,0 +1,165 @@
/**
* @generated SignedSource<<006f8f3dc3c4d41a23cc71a870aa571c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
export type PeopleGraphNodeQuery$variables = {
peopleId: string;
};
export type PeopleGraphNodeQuery$data = {
readonly node: {
readonly additionalEmailAddresses?: ReadonlyArray<string>;
readonly fullName?: string;
readonly id?: string;
readonly kind?: PeopleKind;
readonly primaryEmailAddress?: string;
};
};
export type PeopleGraphNodeQuery = {
response: PeopleGraphNodeQuery$data;
variables: PeopleGraphNodeQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "peopleId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "peopleId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PeopleGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
],
"type": "People",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PeopleGraphNodeQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
],
"type": "People",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "319f5f4ff11db061920a25bbfda43244",
"id": null,
"metadata": {},
"name": "PeopleGraphNodeQuery",
"operationKind": "query",
"text": "query PeopleGraphNodeQuery(\n $peopleId: ID!\n) {\n node(id: $peopleId) {\n __typename\n ... on People {\n id\n fullName\n primaryEmailAddress\n kind\n additionalEmailAddresses\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "b4e6fe403e30fbc27ae638d464ad7543";
export default node;

View File

@@ -0,0 +1,252 @@
/**
* @generated SignedSource<<e4fcd65491a7570cf56382a3f977e06d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
import { FragmentRefs } from "relay-runtime";
export type PeopleGraphPaginatedFragment$data = {
readonly id: string;
readonly peoples: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly additionalEmailAddresses: ReadonlyArray<string>;
readonly fullName: string;
readonly id: string;
readonly kind: PeopleKind;
readonly primaryEmailAddress: string;
};
}>;
};
readonly " $fragmentType": "PeopleGraphPaginatedFragment";
};
export type PeopleGraphPaginatedFragment$key = {
readonly " $data"?: PeopleGraphPaginatedFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"PeopleGraphPaginatedFragment">;
};
import PeopleListQuery_graphql from './PeopleListQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"peoples"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 50,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": PeopleListQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "PeopleGraphPaginatedFragment",
"selections": [
{
"alias": "peoples",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "__PeopleGraphPaginatedQuery_peoples_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "c163b7909a337efd22088852b9908045";
export default node;

View File

@@ -0,0 +1,271 @@
/**
* @generated SignedSource<<43878b22fe8a689f41bcd506711ddb5f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PeopleGraphPaginatedQuery$variables = {
organizationId: string;
};
export type PeopleGraphPaginatedQuery$data = {
readonly organization: {
readonly id?: string;
readonly " $fragmentSpreads": FragmentRefs<"PeopleGraphPaginatedFragment">;
};
};
export type PeopleGraphPaginatedQuery = {
response: PeopleGraphPaginatedQuery$data;
variables: PeopleGraphPaginatedQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 50
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PeopleGraphPaginatedQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "PeopleGraphPaginatedFragment"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PeopleGraphPaginatedQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "peoples",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": "peoples(first:50)"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "PeopleGraphPaginatedQuery_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "b154e94ac7e783a492bc5aed7d44229d",
"id": null,
"metadata": {},
"name": "PeopleGraphPaginatedQuery",
"operationKind": "query",
"text": "query PeopleGraphPaginatedQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n ...PeopleGraphPaginatedFragment\n }\n id\n }\n}\n\nfragment PeopleGraphPaginatedFragment on Organization {\n peoples(first: 50) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n kind\n additionalEmailAddresses\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "2d5f8ae64618a57eeb623b0ade941486";
export default node;

View File

@@ -0,0 +1,142 @@
/**
* @generated SignedSource<<bbf8c99c9d2d556129349cfe82d26582>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
export type UpdatePeopleInput = {
additionalEmailAddresses?: ReadonlyArray<string> | null | undefined;
fullName?: string | null | undefined;
id: string;
kind?: PeopleKind | null | undefined;
primaryEmailAddress?: string | null | undefined;
};
export type PeopleGraphUpdateMutation$variables = {
input: UpdatePeopleInput;
};
export type PeopleGraphUpdateMutation$data = {
readonly updatePeople: {
readonly people: {
readonly additionalEmailAddresses: ReadonlyArray<string>;
readonly fullName: string;
readonly id: string;
readonly kind: PeopleKind;
readonly primaryEmailAddress: string;
};
};
};
export type PeopleGraphUpdateMutation = {
response: PeopleGraphUpdateMutation$data;
variables: PeopleGraphUpdateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdatePeoplePayload",
"kind": "LinkedField",
"name": "updatePeople",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "people",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PeopleGraphUpdateMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PeopleGraphUpdateMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "c61ca0a6b20067ce5e0c708fc55509d8",
"id": null,
"metadata": {},
"name": "PeopleGraphUpdateMutation",
"operationKind": "mutation",
"text": "mutation PeopleGraphUpdateMutation(\n $input: UpdatePeopleInput!\n) {\n updatePeople(input: $input) {\n people {\n id\n fullName\n primaryEmailAddress\n kind\n additionalEmailAddresses\n }\n }\n}\n"
}
};
})();
(node as any).hash = "48c2618963704a8cd3b1f0a68817cdad";
export default node;

View File

@@ -0,0 +1,344 @@
/**
* @generated SignedSource<<bc0fdc02fb8b596386e290ae0cf0b8b8>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type OrderDirection = "ASC" | "DESC";
export type PeopleOrderField = "CREATED_AT" | "FULL_NAME";
export type PeopleOrder = {
direction: OrderDirection;
field: PeopleOrderField;
};
export type PeopleListQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: PeopleOrder | null | undefined;
};
export type PeopleListQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleGraphPaginatedFragment">;
};
};
export type PeopleListQuery = {
response: PeopleListQuery$data;
variables: PeopleListQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 50,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "PeopleListQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "PeopleGraphPaginatedFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "PeopleListQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "PeopleConnection",
"kind": "LinkedField",
"name": "peoples",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
},
(v11/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "PeopleGraphPaginatedQuery_peoples",
"kind": "LinkedHandle",
"name": "peoples"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a17ff68897dbcf3daa33b073bc83f1bb",
"id": null,
"metadata": {},
"name": "PeopleListQuery",
"operationKind": "query",
"text": "query PeopleListQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: PeopleOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PeopleGraphPaginatedFragment_16fISc\n id\n }\n}\n\nfragment PeopleGraphPaginatedFragment_16fISc on Organization {\n peoples(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n kind\n additionalEmailAddresses\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "c163b7909a337efd22088852b9908045";
export default node;

View File

@@ -9,7 +9,6 @@ import {
ActionDropdown,
Button,
Card,
ConfirmDialog,
DropdownItem,
FileButton,
IconChevronDown,
@@ -27,7 +26,7 @@ import {
Th,
Thead,
Tr,
useConfirmDialogRef,
useConfirm,
} from "@probo/ui";
import {
measuresQuery,
@@ -245,33 +244,33 @@ type MeasureRowProps = {
function MeasureRow(props: MeasureRowProps) {
const { __ } = useTranslate();
const [deleteMeasure, isDeleting] = useDeleteMeasureMutation();
const confirm = useConfirm();
const onDelete = () => {
return new Promise<void>((resolve) => {
deleteMeasure({
variables: {
input: { measureId: props.measure.id },
connections: [props.connectionId],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
return (
<>
<ConfirmDialog
message={sprintf(
confirm(
() =>
new Promise<void>((resolve) => {
deleteMeasure({
variables: {
input: { measureId: props.measure.id },
connections: [props.connectionId],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete the measure "%s". This action cannot be undone.'
),
props.measure.name
)}
onConfirm={onDelete}
ref={confirmRef}
/>
),
}
);
};
return (
<>
<Tr>
<Td>{props.measure.name}</Td>
<Td width={120}>
@@ -281,7 +280,7 @@ function MeasureRow(props: MeasureRowProps) {
<ActionDropdown>
<DropdownItem icon={IconPencil}>{__("Edit")}</DropdownItem>
<DropdownItem
onClick={() => confirmRef.current?.open()}
onClick={onDelete}
disabled={isDeleting}
variant="danger"
icon={IconTrashCan}

View File

@@ -0,0 +1,89 @@
import {
ConnectionHandler,
usePreloadedQuery,
type PreloadedQuery,
} from "react-relay";
import type { PeopleGraphNodeQuery } from "/hooks/graph/__generated__/PeopleGraphNodeQuery.graphql";
import {
PeopleConnectionKey,
peopleNodeQuery,
useDeletePeople,
} from "/hooks/graph/PeopleGraph";
import {
ActionDropdown,
Avatar,
Breadcrumb,
DropdownItem,
IconTrashCan,
TabLink,
Tabs,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Outlet } from "react-router";
type Props = {
queryRef: PreloadedQuery<PeopleGraphNodeQuery>;
};
export default function PeopleDetailPage(props: Props) {
const data = usePreloadedQuery(peopleNodeQuery, props.queryRef);
const people = data.node;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const deletePeople = useDeletePeople(
people,
ConnectionHandler.getConnectionID(organizationId, PeopleConnectionKey)
);
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("People"),
to: `/organizations/${organizationId}/people`,
},
{
label: data.node.fullName ?? "",
},
]}
/>
<div className="flex justify-between">
<div className="space-y-4">
<Avatar name={people.fullName ?? ""} size="xl" />
<div className="text-2xl">{people.fullName}</div>
</div>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
<Tabs>
<TabLink
to={`/organizations/${organizationId}/people/${people.id}/tasks`}
>
{__("Tasks")}
</TabLink>
<TabLink
to={`/organizations/${organizationId}/people/${people.id}/role`}
>
{__("Role & access")}
</TabLink>
<TabLink
to={`/organizations/${organizationId}/people/${people.id}/profile`}
>
{__("General information")}
</TabLink>
</Tabs>
<Outlet context={{ people }} />
</div>
);
}

View File

@@ -0,0 +1,127 @@
import {
Button,
IconPlusLarge,
PageHeader,
Thead,
Tbody,
Tr,
Th,
Td,
Avatar,
ActionDropdown,
DropdownItem,
IconPencil,
IconTrashCan,
useConfirm,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { PeopleGraphPaginatedQuery } from "/hooks/graph/__generated__/PeopleGraphPaginatedQuery.graphql";
import { useMutation, type PreloadedQuery } from "react-relay";
import {
deletePeopleMutation,
useDeletePeople,
usePeopleQuery,
} from "/hooks/graph/PeopleGraph";
import { SortableTable, SortableTh } from "/components/SortableTable";
import type { PeopleGraphPaginatedFragment$data } from "/hooks/graph/__generated__/PeopleGraphPaginatedFragment.graphql";
import type { NodeOf } from "/types";
import { usePageTitle } from "@probo/hooks";
import { getRole, sprintf } from "@probo/helpers";
import { CreatePeopleDialog } from "./dialogs/CreatePeopleDialog";
import type { PeopleGraphDeleteMutation } from "/hooks/graph/__generated__/PeopleGraphDeleteMutation.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Link } from "react-router";
type People = NodeOf<PeopleGraphPaginatedFragment$data["peoples"]>;
export default function PeopleListPage({
queryRef,
}: {
queryRef: PreloadedQuery<PeopleGraphPaginatedQuery>;
}) {
const { __ } = useTranslate();
const { people, refetch, connectionId } = usePeopleQuery(queryRef);
usePageTitle(__("Members"));
return (
<div className="space-y-6">
<PageHeader
title={__("Members")}
description={__(
"Keep track of your company's workforce and their progress towards completing tasks assigned to them."
)}
>
<CreatePeopleDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add member")}</Button>
</CreatePeopleDialog>
</PageHeader>
<SortableTable refetch={refetch}>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="KIND">{__("Role")}</SortableTh>
<Th>{__("Actions")}</Th>
</Tr>
</Thead>
<Tbody>
{people.map((person) => (
<PeopleRow
key={person.id}
people={person}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
</div>
);
}
function PeopleRow({
people,
connectionId,
}: {
people: People;
connectionId: string;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deletePeople = useDeletePeople(people, connectionId);
return (
<Tr to={`/organizations/${organizationId}/people/${people.id}/tasks`}>
<Td>
<div className="flex gap-3 items-center">
<Avatar name={people.fullName} />
<div>
<div className="text-sm">{people.fullName}</div>
<div className="text-xs text-txt-tertiary">
{people.primaryEmailAddress}
</div>
</div>
</div>
</Td>
<Td className="text-sm">{getRole(__, people.kind)}</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem asChild>
<Link
to={`/organizations/${organizationId}/people/${people.id}/tasks`}
>
<IconPencil size={16} />
{__("Edit")}
</Link>
</DropdownItem>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,127 @@
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Option,
useDialogRef,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
import z from "zod";
import { getRoles, peopleRoles } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { graphql } from "relay-runtime";
import { ControlledField } from "/components/form/ControlledField";
import { EmailsField } from "/components/form/EmailsField";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connectionId: string;
};
const schema = z.object({
fullName: z.string().min(1),
primaryEmailAddress: z.string().email(),
additionalEmailAddresses: z.preprocess(
// Empty additional emails are skipped
(v) => (v as string[]).filter((v) => !!v),
z.array(z.string().email())
),
kind: z.enum(peopleRoles),
});
export const createPeopleMutation = graphql`
mutation CreatePeopleDialogMutation(
$input: CreatePeopleInput!
$connections: [ID!]!
) {
createPeople(input: $input) {
peopleEdge @prependEdge(connections: $connections) {
node {
id
fullName
primaryEmailAddress
kind
additionalEmailAddresses
}
}
}
}
`;
export function CreatePeopleDialog({ children, connectionId }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { control, handleSubmit, register } = useFormWithSchema(schema, {
defaultValues: {
additionalEmailAddresses: [],
},
});
const ref = useDialogRef();
const [mutate, isMutating] = useMutationWithToasts(createPeopleMutation, {
successMessage: __("Person created successfully."),
errorMessage: __("Failed to create person. Please try again."),
});
const onSubmit = handleSubmit((data) => {
mutate({
variables: {
input: {
...data,
organizationId,
},
connections: [connectionId],
},
onSuccess: () => {
ref.current?.close();
},
});
});
return (
<Dialog
ref={ref}
trigger={children}
title={<Breadcrumb items={[__("People"), __("New Person")]} />}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<Field
label={__("Full name")}
{...register("fullName")}
type="text"
/>
<Field
label={__("Primary email")}
{...register("primaryEmailAddress")}
type="email"
/>
<ControlledField
control={control}
name="kind"
type="select"
label={__("Role")}
>
{getRoles(__).map((role) => (
<Option key={role.value} value={role.value}>
{role.label}
</Option>
))}
</ControlledField>
<EmailsField control={control} register={register} />
</DialogContent>
<DialogFooter>
<Button disabled={isMutating} type="submit">
{__("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,195 @@
/**
* @generated SignedSource<<aa74d7b1c9c34a3780220590119aa78f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type PeopleKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT";
export type CreatePeopleInput = {
additionalEmailAddresses?: ReadonlyArray<string> | null | undefined;
fullName: string;
kind: PeopleKind;
organizationId: string;
primaryEmailAddress: string;
};
export type CreatePeopleDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreatePeopleInput;
};
export type CreatePeopleDialogMutation$data = {
readonly createPeople: {
readonly peopleEdge: {
readonly node: {
readonly additionalEmailAddresses: ReadonlyArray<string>;
readonly fullName: string;
readonly id: string;
readonly kind: PeopleKind;
readonly primaryEmailAddress: string;
};
};
};
};
export type CreatePeopleDialogMutation = {
response: CreatePeopleDialogMutation$data;
variables: CreatePeopleDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"kind": "LinkedField",
"name": "peopleEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "kind",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "additionalEmailAddresses",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "CreatePeopleDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreatePeoplePayload",
"kind": "LinkedField",
"name": "createPeople",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreatePeopleDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreatePeoplePayload",
"kind": "LinkedField",
"name": "createPeople",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "peopleEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "634c2845825a02a0343c3211b41c90a0",
"id": null,
"metadata": {},
"name": "CreatePeopleDialogMutation",
"operationKind": "mutation",
"text": "mutation CreatePeopleDialogMutation(\n $input: CreatePeopleInput!\n) {\n createPeople(input: $input) {\n peopleEdge {\n node {\n id\n fullName\n primaryEmailAddress\n kind\n additionalEmailAddresses\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "867aad1276c26bd787ddf80c6f166abe";
export default node;

View File

@@ -0,0 +1,84 @@
import z from "zod";
import { peopleRoles } from "@probo/helpers";
import { useOutletContext } from "react-router";
import type { PeopleGraphNodeQuery$data } from "/hooks/graph/__generated__/PeopleGraphNodeQuery.graphql";
import { useTranslate } from "@probo/i18n";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { PeopleGraphUpdateMutation } from "/hooks/graph/__generated__/PeopleGraphUpdateMutation.graphql";
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
import { Button, Card, Field } from "@probo/ui";
import { EmailsField } from "/components/form/EmailsField";
const schema = z.object({
fullName: z.string().min(1),
primaryEmailAddress: z.string().email(),
additionalEmailAddresses: z.preprocess(
// Empty additional emails are skipped
(v) => (v as string[]).filter((v) => !!v),
z.array(z.string().email())
),
kind: z.enum(peopleRoles),
});
export default function PeopleProfileTab() {
const { people } = useOutletContext<{
people: PeopleGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { control, formState, handleSubmit, register, reset } =
useFormWithSchema(schema, {
defaultValues: {
kind: people.kind,
fullName: people.fullName,
primaryEmailAddress: people.primaryEmailAddress,
additionalEmailAddresses: [...(people.additionalEmailAddresses ?? [])],
},
});
const [mutate, isMutating] = useMutationWithToasts<PeopleGraphUpdateMutation>(
updatePeopleMutation,
{
successMessage: __("Member updated successfully."),
errorMessage: __("Failed to update member. Please try again."),
}
);
const onSubmit = handleSubmit((data) => {
mutate({
variables: {
input: {
id: people.id!,
fullName: data.fullName,
primaryEmailAddress: data.primaryEmailAddress,
additionalEmailAddresses: data.additionalEmailAddresses,
// TODO : make these field optional in the query (server side)
kind: people.kind,
},
},
onCompleted: () => {
reset(data);
},
});
});
return (
<form onSubmit={onSubmit} className="space-y-4">
<Card padded className="space-y-4">
<Field label={__("Full name")} {...register("fullName")} type="text" />
<Field
label={__("Primary email")}
{...register("primaryEmailAddress")}
type="email"
/>
<EmailsField control={control} register={register} />
</Card>
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,112 @@
import { useTranslate } from "@probo/i18n";
import { Button, Card, IconCheckmark1 } from "@probo/ui";
import type { PropsWithChildren } from "react";
import z from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { ControlledField } from "/components/form/ControlledField";
import { Option } from "@probo/ui";
import { getRoles, peopleRoles } from "@probo/helpers";
import type { PeopleGraphNodeQuery$data } from "/hooks/graph/__generated__/PeopleGraphNodeQuery.graphql";
import { useOutletContext } from "react-router";
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { PeopleGraphUpdateMutation } from "/hooks/graph/__generated__/PeopleGraphUpdateMutation.graphql";
const schema = z.object({
kind: z.enum(peopleRoles),
});
export default function PeopleRoleTab() {
const { people } = useOutletContext<{
people: PeopleGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { control, formState, handleSubmit, reset } = useFormWithSchema(
schema,
{
defaultValues: {
kind: people.kind,
},
}
);
const [mutate, isMutating] = useMutationWithToasts<PeopleGraphUpdateMutation>(
updatePeopleMutation,
{
successMessage: __("Member updated successfully."),
errorMessage: __("Failed to update member. Please try again."),
}
);
const onSubmit = handleSubmit((data) => {
mutate({
variables: {
input: {
id: people.id!,
kind: data.kind,
// TODO : make these field optional in the query (server side)
fullName: people.fullName!,
primaryEmailAddress: people.primaryEmailAddress!,
additionalEmailAddresses: people.additionalEmailAddresses ?? [],
},
},
onCompleted: () => {
reset(data);
},
});
});
return (
<form onSubmit={onSubmit} className="space-y-4">
<Card padded className="space-y-4">
<ControlledField
control={control}
name="kind"
type="select"
label={__("Role")}
>
{getRoles(__).map((role) => (
<Option key={role.value} value={role.value}>
{role.label}
</Option>
))}
</ControlledField>
<div className="space-y-2 ">
<div className="text-sm font-medium">{__("Permissions")}</div>
<ul className="text-sm text-txt-tertiary space-y-2">
<AccessItem>
{__("Access dashboard & reports relevant to their team")}
</AccessItem>
<AccessItem>
{__("Create and manage own tasks, tickets, or projects")}
</AccessItem>
<AccessItem>
{__("Comment on shared documents or projects")}
</AccessItem>
<AccessItem>
{__("Receive notifications and system alerts")}
</AccessItem>
<AccessItem>
{__("Join and participate in team chats or threads")}
</AccessItem>
</ul>
</div>
</Card>
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
)}
</div>
</form>
);
}
function AccessItem({ children }: PropsWithChildren) {
return (
<li className="flex gap-2 items-center">
<IconCheckmark1 size={16} />
{children}
</li>
);
}

View File

@@ -0,0 +1,7 @@
export default function PeopleTasksTab() {
return (
<div className="text-sm text-txt-tertiary text-center">
Not available yet
</div>
);
}

View File

@@ -9,10 +9,10 @@ import {
Td,
Avatar,
Badge,
ConfirmDialog,
IconTrashCan,
Button,
IconPlusLarge,
useConfirm,
} from "@probo/ui";
import {
useFragment,
@@ -151,18 +151,30 @@ function PolicyRow({
const signedCount = signatures.filter(
(signature) => signature.state === "SIGNED"
).length;
const [deletePolicy] = useDeletePolicyMutation();
const [deletePolicy, isDeleting] = useDeletePolicyMutation();
const confirm = useConfirm();
const handleDelete = () => {
return new Promise<void>((resolve) => {
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
});
confirm(
() =>
new Promise<void>((resolve) => {
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete the policy "%s". This action cannot be undone.'
),
policy.title
),
}
);
};
return (
@@ -203,17 +215,12 @@ function PolicyRow({
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<ConfirmDialog
message={sprintf(
__(
'This will permanently delete the policy "%s". This action cannot be undone.'
),
policy.title
)}
onConfirm={handleDelete}
>
<Button icon={IconTrashCan} variant="danger" />
</ConfirmDialog>
<Button
icon={IconTrashCan}
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
/>
</Td>
</Tr>
</>

View File

@@ -25,13 +25,12 @@ import {
Badge,
Avatar,
DropdownItem,
ConfirmDialog,
ActionDropdown,
IconTrashCan,
IconPencil,
useConfirmDialogRef,
IconClock,
IconSignature,
useConfirm,
} from "@probo/ui";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Button } from "@probo/ui";
@@ -136,27 +135,39 @@ export default function PolicyPage(props: Props) {
});
};
const confirm = useConfirm();
const handleDelete = () => {
return new Promise<void>((resolve) => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
PoliciesConnectionKey
);
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/policies`);
},
onError: () => resolve(),
});
});
confirm(
() =>
new Promise<void>((resolve) => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
PoliciesConnectionKey
);
deletePolicy({
variables: {
input: { policyId: policy.id },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/policies`);
},
onError: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete the policy "%s". This action cannot be undone.'
),
policy.title
),
}
);
};
const updateDialogRef = useRef<{ open: () => void }>(null);
const confirmRef = useConfirmDialogRef();
return (
<>
@@ -165,17 +176,6 @@ export default function PolicyPage(props: Props) {
policy={policy}
connectionId={versionConnectionId}
/>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the policy "%s". This action cannot be undone.'
),
policy.title
)}
onConfirm={handleDelete}
/>
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<Breadcrumb
@@ -221,7 +221,7 @@ export default function PolicyPage(props: Props) {
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={() => confirmRef.current?.open()}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>

View File

@@ -4,7 +4,6 @@ import {
Badge,
Breadcrumb,
Button,
ConfirmDialog,
Drawer,
DropdownItem,
IconPencil,
@@ -13,7 +12,7 @@ import {
PropertyRow,
TabLink,
Tabs,
useConfirmDialogRef,
useConfirm,
} from "@probo/ui";
import { Outlet, useNavigate, useParams } from "react-router";
import { useTranslate } from "@probo/i18n";
@@ -49,28 +48,38 @@ export default function RiskDetailPage(props: Props) {
const [deleteRisk] = useDeleteRiskMutation();
usePageTitle(risk.name ?? "Risk detail");
const confirm = useConfirm();
const onDelete = (riskId: string) => {
const onDelete = () => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
RisksConnectionKey
);
return new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
resolve();
},
});
});
confirm(
() =>
new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
resolve();
},
});
}),
{
message: sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
),
}
);
};
const confirmRef = useConfirmDialogRef();
return (
<div className="space-y-6">
{/* Header */}
@@ -99,7 +108,7 @@ export default function RiskDetailPage(props: Props) {
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={() => confirmRef.current?.open()}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
@@ -107,17 +116,6 @@ export default function RiskDetailPage(props: Props) {
</div>
</div>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(riskId)}
/>
<PageHeader title={risk.name} />
<Tabs>

View File

@@ -1,7 +1,6 @@
import {
ActionDropdown,
Button,
ConfirmDialog,
DropdownItem,
IconPencil,
IconPlusLarge,
@@ -14,16 +13,15 @@ import {
Th,
Thead,
Tr,
useConfirmDialogRef,
useConfirm,
useDialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import FormRiskDialog from "./FormRiskDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useState } from "react";
import { usePageTitle } from "@probo/hooks";
import { getTreatment, sprintf } from "@probo/helpers";
import type { ItemOf, NodeOf } from "/types";
import type { NodeOf } from "/types";
import { useDeleteRiskMutation, useRisksQuery } from "/hooks/graph/RiskGraph";
import { SortableTable, SortableTh } from "/components/SortableTable";
import type { PreloadedQuery } from "react-relay";
@@ -103,31 +101,32 @@ function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const { risk, connectionId, organizationId } = props;
const [deleteRisk] = useDeleteRiskMutation();
const onDelete = (riskId: string) => {
return new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
const formDialogRef = useDialogRef();
return (
<>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
const confirm = useConfirm();
const onDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteRisk({
variables: {
input: { riskId: risk.id },
connections: [connectionId],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete the risk "%s". This action cannot be undone.'
),
risk.name
)}
onConfirm={() => onDelete(risk.id)}
/>
),
}
);
};
const formDialogRef = useDialogRef();
return (
<>
<FormRiskDialog
ref={formDialogRef}
risk={risk}
@@ -147,7 +146,7 @@ function RiskRow(props: RowProps) {
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => confirmRef.current?.open()}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
@@ -155,7 +154,7 @@ function RiskRow(props: RowProps) {
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={() => confirmRef.current?.open()}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>

View File

@@ -14,8 +14,7 @@ import {
ActionDropdown,
DropdownItem,
IconTrashCan,
ConfirmDialog,
useConfirmDialogRef,
useConfirm,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
@@ -133,40 +132,40 @@ function VendorRow({
: false;
const [deleteVendor] = useDeleteVendorMutation();
const confirm = useConfirm();
const onDelete = (vendorId: string) => {
return new Promise<void>((resolve) => {
deleteVendor({
variables: {
input: {
vendorId,
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"VendorsPage_vendors"
),
],
},
onCompleted: () => resolve(),
});
});
};
const confirmRef = useConfirmDialogRef();
return (
<>
<ConfirmDialog
ref={confirmRef}
message={sprintf(
const onDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteVendor({
variables: {
input: {
vendorId: vendor.id,
},
connections: [
ConnectionHandler.getConnectionID(
organizationId,
"VendorsPage_vendors"
),
],
},
onCompleted: () => resolve(),
});
}),
{
message: sprintf(
__(
'This will permanently delete the vendor "%s". This action cannot be undone.'
),
vendor.name
)}
onConfirm={() => onDelete(vendor.id)}
/>
),
}
);
};
return (
<>
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}`}>
<Td>
<div className="flex gap-2 items-center">
@@ -194,10 +193,10 @@ function VendorRow({
{isExpired ? __("Late") : __("In progress")}
</Badge>
</Td>
<Td noLink>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
onClick={() => confirmRef.current?.open()}
onClick={onDelete}
variant="danger"
icon={IconTrashCan}
>

View File

@@ -30,7 +30,7 @@ const fetchRelay: FetchFunction = async (
request,
variables,
_,
uploadables,
uploadables
) => {
const requestInit: RequestInit = {
method: "POST",
@@ -46,7 +46,7 @@ const fetchRelay: FetchFunction = async (
operationName: request.name,
query: request.text,
variables: variables,
}),
})
);
const uploadableMap: {
@@ -80,7 +80,7 @@ const fetchRelay: FetchFunction = async (
const response = await fetch(
import.meta.env.VITE_API_URL + "/api/console/v1/query",
requestInit,
requestInit
);
if (response.status === 500) {
@@ -100,8 +100,8 @@ const fetchRelay: FetchFunction = async (
`Error fetching GraphQL query '${
request.name
}' with variables '${JSON.stringify(variables)}': ${JSON.stringify(
json.errors,
)}`,
json.errors
)}`
);
}
@@ -122,10 +122,10 @@ export const relayEnvironment = new Environment({
/**
* Provider for relay with the probo environment
*/
export const RelayProvider = ({ children }: PropsWithChildren) => {
export function RelayProvider({ children }: PropsWithChildren) {
return (
<RelayEnvironmentProvider environment={relayEnvironment}>
{children}
</RelayEnvironmentProvider>
);
};
}

View File

@@ -1,5 +1,6 @@
import {
createBrowserRouter,
Navigate,
useLoaderData,
useRouteError,
type RouteObject,
@@ -18,13 +19,13 @@ import { riskRoutes } from "./routes/riskRoutes.ts";
import { measureRoutes } from "./routes/measureRoutes.ts";
import { policiesRoutes } from "./routes/policiesRoutes.ts";
import { organizationViewQuery } from "./hooks/graph/OrganizationGraph.ts";
import { peopleRoutes } from "./routes/peopleRoutes.ts";
function ErrorBoundary() {
const error = useRouteError();
if (error instanceof UnAuthenticatedError) {
return <div></div>;
return <Navigate to="/auth/login" />;
}
console.log(error);
return <div>error</div>;
}
@@ -50,6 +51,7 @@ const routes = [
path: "/",
Component: CenteredLayout,
fallback: CenteredLayoutSkeleton,
ErrorBoundary: ErrorBoundary,
children: [
{
path: "",
@@ -87,6 +89,7 @@ const routes = [
...riskRoutes,
...measureRoutes,
...policiesRoutes,
...peopleRoutes,
],
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,48 @@
import { lazy } from "react";
import { loadQuery } from "react-relay";
import type { AppRoute } from "/routes.tsx";
import { relayEnvironment } from "/providers/RelayProviders";
import { PageSkeleton } from "/components/skeletons/PageSkeleton.tsx";
import {
paginatedPeopleQuery,
peopleNodeQuery,
} from "/hooks/graph/PeopleGraph";
export const peopleRoutes = [
{
path: "people",
fallback: PageSkeleton,
queryLoader: ({ organizationId }) =>
loadQuery(relayEnvironment, paginatedPeopleQuery, { organizationId }),
Component: lazy(() => import("/pages/organizations/people/PeopleListPage")),
},
{
path: "people/:peopleId",
fallback: PageSkeleton,
queryLoader: ({ peopleId }) =>
loadQuery(relayEnvironment, peopleNodeQuery, { peopleId }),
Component: lazy(
() => import("/pages/organizations/people/PeopleDetailPage")
),
children: [
{
path: "tasks",
Component: lazy(
() => import("/pages/organizations/people/tabs/PeopleTasksTab")
),
},
{
path: "role",
Component: lazy(
() => import("/pages/organizations/people/tabs/PeopleRoleTab")
),
},
{
path: "profile",
Component: lazy(
() => import("/pages/organizations/people/tabs/PeopleProfileTab")
),
},
],
},
] satisfies AppRoute[];

View File

@@ -9,3 +9,4 @@ export {
export { times, groupBy, isEmpty } from "./array";
export { randomInt } from "./number";
export { getMeasureStateLabel, measureStates } from "./measure";
export { getRole, getRoles, peopleRoles } from "./people";

View File

@@ -0,0 +1,37 @@
type Translator = (s: string) => string;
export const peopleRoles = [
"EMPLOYEE",
"CONTRACTOR",
"SERVICE_ACCOUNT",
] as const;
export function getRoles(__: Translator) {
return [
{
value: "EMPLOYEE",
label: __("Employee"),
},
{
value: "CONTRACTOR",
label: __("Contractor"),
},
{
value: "SERVICE_ACCOUNT",
label: __("Service account"),
},
];
}
export function getRole(__: Translator, role?: string): string {
switch (role) {
case "EMPLOYEE":
return __("Employee");
case "CONTRACTOR":
return __("Contractor");
case "SERVICE_ACCOUNT":
return __("Service account");
default:
return __("Unknown");
}
}

View File

@@ -5,13 +5,13 @@ import { Outlet } from "react-router";
export function AuthLayout() {
const { __ } = useTranslate();
return (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen">
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
<div className="bg-level-0 flex flex-col items-center justify-center">
<div className="max-w-112">
<Outlet />
</div>
</div>
<div className="hidden lg:flex bg-dialog text-invert text-5xl font-bold flex flex-col items-center justify-center p-8 text-balance lg:p-10">
<div className="hidden lg:flex bg-dialog text-invert text-5xl font-bold flex flex-col items-center justify-center p-8 text-txt-primary lg:p-10">
<div className="flex flex-col 2xl:flex-row-reverse items-center justify-center gap-4">
<img src={logo} alt="Probo logo" className="h-auto w-96" />
<span>

View File

@@ -12,6 +12,7 @@ import { Logo } from "../Atoms/Logo/Logo.tsx";
import { Toasts } from "../Atoms/Toasts/Toasts.tsx";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { ConfirmDialog } from "../Molecules/Dialog/ConfirmDialog.tsx";
type Props = PropsWithChildren<{
header: ReactNode;
@@ -62,6 +63,7 @@ export function Layout({ header, sidebar, children }: Props) {
</main>
</div>
<Toasts />
<ConfirmDialog />
</div>
</LayoutContext>
);

View File

@@ -9,6 +9,7 @@ import {
Cancel,
} from "@radix-ui/react-alert-dialog";
import {
useCallback,
useRef,
useState,
type ComponentProps,
@@ -18,57 +19,94 @@ import {
import { Button } from "../../Atoms/Button/Button";
import { Root as Portal } from "@radix-ui/react-portal";
import { dialog } from "./Dialog";
import { create } from "zustand";
import { combine } from "zustand/middleware";
type Props = {
children?: ReactNode;
title?: ReactNode;
message: string;
type State = {
title?: string;
message: string | null;
variant?: ComponentProps<typeof Button>["variant"];
label?: string;
onConfirm?: () => Promise<void>;
ref?: RefObject<{ open: () => void } | null>;
onConfirm: () => Promise<void>;
};
export const useConfirmDialogRef = () => {
return useRef<{ open: () => void } | null>(null);
};
const useConfirmStore = create(
combine(
{
message: null,
onConfirm: () => Promise.resolve(),
} as State,
(set) => ({
open: (props: State) => {
set(props);
},
close: () => {
set({
message: null,
});
},
}),
),
);
export function ConfirmDialog(props: Props) {
/**
* Hook used to open a confirm dialog
*/
export function useConfirm() {
const open = useConfirmStore((state) => state.open);
const { __ } = useTranslate();
const title = props.title ?? __("Are you sure ?");
const variant = props.variant ?? "danger";
const label = variant === "danger" ? __("Delete") : props.label;
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
return useCallback(
(cb: State["onConfirm"], props: Omit<State, "onConfirm">) => {
open({
onConfirm: cb,
...props,
message: props.message,
title: props.title ?? __("Are you sure ?"),
variant: props.variant ?? "danger",
label: props.label ?? __("Delete"),
});
},
[open],
);
}
/**
* Global component that displays a dialog when confirm() is called
*/
export function ConfirmDialog() {
const { message, title, variant, label, onConfirm, close } =
useConfirmStore();
const { __ } = useTranslate();
const isOpen = !!message;
const {
overlay,
content,
header,
footer,
title: titleClassname,
footer,
} = dialog();
const onConfirm = () => {
const [loading, setLoading] = useState(false);
const handleConfirm = () => {
setLoading(true);
props.onConfirm?.().then(() => {
setLoading(false);
setOpen(false);
});
onConfirm()
.then(() => {
close();
})
.finally(() => {
setLoading(false);
});
};
if (props.ref) {
props.ref.current = {
open: () => setOpen(true),
};
}
return (
<Root open={open} onOpenChange={setOpen}>
{props.children && <Trigger asChild children={props.children} />}
<Root open={isOpen} onOpenChange={close}>
<Portal>
<Overlay className={overlay()} />
<Content className={content({ className: "max-w-[500px]" })}>
<header className={header()}>
<Title children={title} className={titleClassname()} />
</header>
<Description className="p-6" children={props.message} />
<Description className="p-6" children={message} />
<footer className={footer()}>
<Cancel asChild>
<Button disabled={loading} variant="tertiary">
@@ -78,7 +116,7 @@ export function ConfirmDialog(props: Props) {
<Button
disabled={loading}
variant={variant}
onClick={onConfirm}
onClick={handleConfirm}
>
{label}
</Button>

View File

@@ -47,13 +47,10 @@ export {
DialogTitle,
useDialogRef,
} from "./Molecules/Dialog/Dialog";
export { useConfirm } from "./Molecules/Dialog/ConfirmDialog";
export { RiskBadge } from "./Molecules/Badge/RiskBadge";
export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx";
export { PolicyVersionBadge } from "./Molecules/Badge/PolicyVersionBadge.tsx";
export {
ConfirmDialog,
useConfirmDialogRef,
} from "./Molecules/Dialog/ConfirmDialog.tsx";
export { RisksChart } from "./Molecules/Risks/RisksChart";
export { RiskOverview } from "./Molecules/Risks/RiskOverview";
export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox";