Merge pull request #10255 from joel-kalema/feat/polish-key-result-dialogs

Feat/polish key result dialogs
This commit is contained in:
joel kalema
2026-09-21 11:32:27 +02:00
committed by GitHub
parent 3f45758e76
commit ec1f6d3f7f
8 changed files with 1013 additions and 546 deletions
@@ -1,307 +1,219 @@
<nb-card class="max-width-60vw">
<nb-card-header class="d-flex flex-column">
<span class="cancel"
><i class="fas fa-times" (click)="closeDialog()"></i
></span>
<h4 class="title">
{{
(!!data
? 'KEY_RESULT_PAGE.EDIT_KEY_RESULT'
: 'KEY_RESULT_PAGE.ADD_KEY_RESULT'
) | translate
}}
</h4>
</nb-card-header>
<nb-card-body>
<form [formGroup]="keyResultsForm">
<p
class="helper-text"
innerHtml="{{
'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_GENERAL' | translate
}}"
></p>
<div class="row">
<div class="col-md-12">
<label for="key-result-title" class="label">
{{ 'FORM.LABELS.NAME' | translate }}
</label>
<input
type="text"
id="key-result-title"
nbInput
fullWidth
formControlName="name"
placeholder="{{
'KEY_RESULT_PAGE.FORM.PLACEHOLDERS.NAME' | translate
}}"
/>
</div>
</div>
<nb-card class="key-result-dialog">
<nb-card-header class="header">
<h4 class="title">
{{ (!!data ? 'KEY_RESULT_PAGE.EDIT_KEY_RESULT' : 'KEY_RESULT_PAGE.ADD_KEY_RESULT') | translate }}
</h4>
<button type="button" class="cancel" [attr.aria-label]="'BUTTONS.CLOSE' | translate" (click)="closeDialog()">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</nb-card-header>
<div class="row">
<div class="col-md-12">
<label for="key-result-description" class="label mt-3">
{{ 'FORM.LABELS.DESCRIPTION_OPTIONAL' | translate }}
</label>
<textarea
id="key-result-description"
nbInput
fullWidth
formControlName="description"
placeholder="{{
'KEY_RESULT_PAGE.FORM.PLACEHOLDERS.DESCRIPTION'
| translate
}}"
></textarea>
</div>
</div>
<nb-card-body>
<!-- The form on a six-column grid with the help as a column beside it, as in the objective dialog: it
used to run one field per row down 645px, with the general help as a paragraph on top. Hover and
focus go to separate handlers, resolved together in the component, so leaving a field with the
pointer does not wipe out the help the keyboard is still on. -->
<div class="key-result-layout">
<form class="key-result-form" [formGroup]="keyResultsForm">
<div
class="field span-6"
(mouseenter)="hoverHelper('key-result-title')"
(mouseleave)="hoverHelper('')"
(focusin)="focusHelper('key-result-title')"
(focusout)="focusHelper('')"
>
<label for="key-result-title" class="label">
{{ 'FORM.LABELS.NAME' | translate }}
</label>
<input
type="text"
id="key-result-title"
nbInput
fullWidth
formControlName="name"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.PLACEHOLDERS.NAME' | translate }}"
/>
</div>
<ga-keyresult-type-select
[parentFormGroup]="keyResultsForm"
[numberUnits]="numberUnitsEnum"
[orgId]="orgId"
[settings]="settings"
[KPIs]="KPIs"
></ga-keyresult-type-select>
<div class="field span-6">
<label for="key-result-description" class="label">
{{ 'FORM.LABELS.DESCRIPTION_OPTIONAL' | translate }}
</label>
<textarea
id="key-result-description"
nbInput
fullWidth
formControlName="description"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.PLACEHOLDERS.DESCRIPTION' | translate }}"
></textarea>
</div>
<div class="row">
<div
class="col-md-7"
(mouseenter)="helperText = 'key-result-owner'"
(mouseleave)="helperText = ''"
>
<label for="key-result-owner" class="label mt-3">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.OWNER' | translate }}
</label>
<ga-employee-multi-select
[multiple]="false"
[allEmployees]="employees"
[selectedEmployeeIds]="keyResultsForm.value.ownerId"
[label]="false"
(selectedChange)="selectEmployee($event, 'owner')"
id="key-result-owner"
placeholder="{{
'KEY_RESULT_PAGE.FORM.LABELS.OWNER' | translate
}}"
class="header-selector employee-selector"
></ga-employee-multi-select>
</div>
<div class="col-md-5 position-relative mt-3 helper-text">
@if (helperText == 'key-result-owner') {
<div
class="mt-3 position-absolute"
>
<p>
{{
'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_OWNER'
| translate
}}
</p>
</div>
}
</div>
</div>
<!-- Type, and whatever that type needs: its fields are items of this grid. -->
<ga-keyresult-type-select
[parentFormGroup]="keyResultsForm"
[numberUnits]="numberUnitsEnum"
[orgId]="orgId"
[settings]="settings"
[KPIs]="KPIs"
></ga-keyresult-type-select>
<div class="row">
<div
class="col-md-7"
(mouseenter)="helperText = 'key-result-lead'"
(mouseleave)="helperText = ''"
>
<label for="key-result-lead" class="label mt-3">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.LEAD' | translate }}
</label>
<ga-employee-multi-select
[multiple]="false"
[allEmployees]="employees"
[selectedEmployeeIds]="keyResultsForm.value.leadId"
[label]="false"
(selectedChange)="selectEmployee($event, 'lead')"
id="key-result-lead"
placeholder="{{
'KEY_RESULT_PAGE.FORM.LABELS.LEAD' | translate
}}"
class="header-selector employee-selector"
></ga-employee-multi-select>
</div>
<div class="col-md-5 position-relative mt-3 helper-text">
@if (helperText == 'key-result-lead') {
<div
class="mt-3 helper-text position-absolute"
>
<p>
{{
'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_LEAD'
| translate
}}
</p>
</div>
}
</div>
</div>
<div
class="field span-3"
(mouseenter)="hoverHelper('key-result-owner')"
(mouseleave)="hoverHelper('')"
(focusin)="focusHelper('key-result-owner')"
(focusout)="focusHelper('')"
>
<label for="key-result-owner" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.OWNER' | translate }}
</label>
<ga-employee-multi-select
[multiple]="false"
[allEmployees]="employees"
[selectedEmployeeIds]="keyResultsForm.value.ownerId"
[label]="false"
(selectedChange)="selectEmployee($event, 'owner')"
id="key-result-owner"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.OWNER' | translate }}"
class="header-selector employee-selector"
></ga-employee-multi-select>
</div>
<div class="row">
<div class="col-md-7">
<label for="key-result-deadline" class="label mt-3">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.DEADLINE' | translate }}
</label>
<nb-select
(selectedChange)="deadlineValidators()"
id="key-result-deadline"
fullWidth
formControlName="deadline"
>
@for (
deadline of keyResultDeadlineEnum | keyvalue
; track
deadline) {
<nb-option
[value]="deadline.value"
>
{{
'KEY_RESULT_PAGE.DEADLINE.' + deadline.key
| translate
}}
</nb-option>
}
</nb-select>
</div>
<div class="col-md-5 position-relative mt-3"></div>
</div>
<div
class="field span-3"
(mouseenter)="hoverHelper('key-result-lead')"
(mouseleave)="hoverHelper('')"
(focusin)="focusHelper('key-result-lead')"
(focusout)="focusHelper('')"
>
<label for="key-result-lead" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.LEAD' | translate }}
</label>
<ga-employee-multi-select
[multiple]="false"
[allEmployees]="employees"
[selectedEmployeeIds]="keyResultsForm.value.leadId"
[label]="false"
(selectedChange)="selectEmployee($event, 'lead')"
id="key-result-lead"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.LEAD' | translate }}"
class="header-selector employee-selector"
></ga-employee-multi-select>
</div>
<div class="row">
<div class="col-md-7">
<div class="row mt-3">
@if (
keyResultsForm.value.deadline ==
keyResultDeadlineEnum.HARD_AND_SOFT_DEADLINE
) {
<div
class="col-md-6"
>
<label for="soft-deadline" class="label">
{{
'KEY_RESULT_PAGE.FORM.LABELS.SOFT_DEADLINE'
| translate
}}
</label>
<input
nbInput
id="soft-deadline"
fullWidth
placeholder="{{
'KEY_RESULT_PAGE.FORM.LABELS.SOFT_DEADLINE'
| translate
}}"
[nbDatepicker]="softDeadlinePicker"
formControlName="softDeadline"
/>
<nb-datepicker
#softDeadlinePicker
[min]="minDate"
[max]="
keyResultsForm.value.hardDeadline
? keyResultsForm.value.hardDeadline
: null
"
></nb-datepicker>
</div>
}
@if (
keyResultsForm.value.deadline ==
keyResultDeadlineEnum.HARD_AND_SOFT_DEADLINE ||
keyResultsForm.value.deadline ==
keyResultDeadlineEnum.HARD_DEADLINE
) {
<div
class="col-md-6"
>
<label for="hard-deadline" class="label">
{{
'KEY_RESULT_PAGE.FORM.LABELS.HARD_DEADLINE'
| translate
}}
</label>
<input
nbInput
id="hard-deadline"
fullWidth
placeholder="{{
'KEY_RESULT_PAGE.FORM.LABELS.HARD_DEADLINE'
| translate
}}"
[nbDatepicker]="hardDeadlinePicker"
formControlName="hardDeadline"
/>
<nb-datepicker
#hardDeadlinePicker
[min]="
keyResultsForm.value.softDeadline
? keyResultsForm.value.softDeadline
: minDate
"
></nb-datepicker>
</div>
}
</div>
</div>
<div class="col-md-5 position-relative mt-3"></div>
</div>
<div class="field span-3">
<label for="key-result-deadline" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.DEADLINE' | translate }}
</label>
<nb-select
(selectedChange)="deadlineValidators()"
id="key-result-deadline"
fullWidth
formControlName="deadline"
>
@for (deadline of keyResultDeadlineEnum | keyvalue; track deadline) {
<nb-option [value]="deadline.value">
{{ 'KEY_RESULT_PAGE.DEADLINE.' + deadline.key | translate }}
</nb-option>
}
</nb-select>
</div>
<div class="row">
<div class="col-md-7">
<div class="row mt-3">
<div class="col-12">
<div class="d-flex flex-row align-items-start">
<nb-toggle
id="updated-value"
formControlName="assignAsObjective"
status="primary"
>
</nb-toggle>
<span class="mr-3 ml-3">{{
'KEY_RESULT_PAGE.FORM.LABELS.ASSIGN_AS_OBJECTIVE'
| translate
}}</span>
</div>
</div>
</div>
</div>
<div class="col-md-5 position-relative mt-3"></div>
</div>
<ga-goal-level-select
[parentFormGroup]="keyResultsForm"
[orgId]="orgId"
[teams]="teams"
[hideOrg]="hideOrg"
[hideEmployee]="hideEmployee"
[hideTeam]="hideTeam"
[helperText]="helperText"
[employees]="employees"
[orgName]="orgName"
[alignedGoal]="true"
[enableHelperText]="true"
>
</ga-goal-level-select>
</form>
</nb-card-body>
<nb-card-footer class="text-left">
<button
status="basic"
outline
class="mr-2"
nbButton
(click)="closeDialog()"
>
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button
[disabled]="keyResultsForm.invalid"
nbButton
status="success"
(click)="saveKeyResult()"
>
{{ 'BUTTONS.SAVE' | translate }}
</button>
</nb-card-footer>
</nb-card>
@if (keyResultsForm.value.deadline == keyResultDeadlineEnum.HARD_AND_SOFT_DEADLINE) {
<div class="field span-3">
<label for="soft-deadline" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.SOFT_DEADLINE' | translate }}
</label>
<input
nbInput
id="soft-deadline"
fullWidth
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.SOFT_DEADLINE' | translate }}"
[nbDatepicker]="softDeadlinePicker"
formControlName="softDeadline"
/>
<nb-datepicker
#softDeadlinePicker
[min]="minDate"
[max]="keyResultsForm.value.hardDeadline ? keyResultsForm.value.hardDeadline : null"
></nb-datepicker>
</div>
} @if (keyResultsForm.value.deadline == keyResultDeadlineEnum.HARD_AND_SOFT_DEADLINE ||
keyResultsForm.value.deadline == keyResultDeadlineEnum.HARD_DEADLINE) {
<div class="field span-3">
<label for="hard-deadline" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.HARD_DEADLINE' | translate }}
</label>
<input
nbInput
id="hard-deadline"
fullWidth
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.HARD_DEADLINE' | translate }}"
[nbDatepicker]="hardDeadlinePicker"
formControlName="hardDeadline"
/>
<nb-datepicker
#hardDeadlinePicker
[min]="keyResultsForm.value.softDeadline ? keyResultsForm.value.softDeadline : minDate"
></nb-datepicker>
</div>
}
<!-- Turning this on adds the objective fields below, so it reads as the switch it is. -->
<div class="field span-6 toggle-field">
<!-- The label is projected into nb-toggle, so it sits inside the control's own
<label> and names the switch for a screen reader. -->
<nb-toggle id="updated-value" formControlName="assignAsObjective" status="primary">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.ASSIGN_AS_OBJECTIVE' | translate }}
</nb-toggle>
</div>
<ga-goal-level-select
[parentFormGroup]="keyResultsForm"
[orgId]="orgId"
[teams]="teams"
[hideOrg]="hideOrg"
[hideEmployee]="hideEmployee"
[hideTeam]="hideTeam"
[employees]="employees"
[orgName]="orgName"
[alignedGoal]="true"
[enableHelperText]="false"
(helperTextChange)="levelHelper($event)"
>
</ga-goal-level-select>
</form>
<!-- Follows the pointer or the keyboard focus from field to field, and falls back to what a Key
Result is. A polite live region, so a screen reader reads the new help as focus moves. -->
<aside class="key-result-help" aria-live="polite" aria-atomic="true">
@switch (helperText) { @case ('key-result-owner') {
<p>{{ 'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_OWNER' | translate }}</p>
} @case ('key-result-lead') {
<p>{{ 'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_LEAD' | translate }}</p>
} @case ('objective-level') {
<p>{{ 'GOALS_PAGE.HELPER_TEXT.OBJECTIVE_LEVEL' | translate }}</p>
} @case ('objective-owner') {
<p>{{ 'GOALS_PAGE.HELPER_TEXT.OBJECTIVE_OWNER' | translate }}</p>
} @default {
<p [innerHTML]="'KEY_RESULT_PAGE.HELPER_TEXT.KEY_RESULT_GENERAL' | translate"></p>
} }
</aside>
</div>
</nb-card-body>
<nb-card-footer class="footer">
<button nbButton ghost status="basic" size="small" (click)="closeDialog()">
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button
[disabled]="keyResultsForm.invalid"
nbButton
status="success"
size="small"
(click)="saveKeyResult()"
>
{{ 'BUTTONS.SAVE' | translate }}
</button>
</nb-card-footer>
</nb-card>
@@ -1,53 +1,336 @@
@use 'gauzy/_gauzy-dialogs' as *;
.max-width-60vw {
width: 645px;
}
// The Add / Edit Key Result dialog, on the same scale as the objective and KPI
// dialogs: a header row, 12px labels over 2rem controls on a six-column grid,
// the hover / focus help as a quiet column beside the form, and a footer whose
// buttons sit on the trailing edge. Every custom property carries a fallback —
// `--gauzy-*` is undefined in material-light / material-dark, where a `var()`
// without one is dropped.
$control-height: 2rem;
$control-font-size: 12px;
$control-padding-x: 0.75rem;
$inset: 1rem;
$divider: 1px solid var(--gauzy-border-default-color, rgba(126, 126, 143, 0.18));
$muted: var(--gauzy-text-color-2, rgba(126, 126, 143, 1));
// Below this the six columns leave the paired fields too narrow beside the
// 14rem help column, so the help moves under the form.
$stack-below: 991.98px;
// Below this the form itself goes to one column.
$single-column-below: 575.98px;
// 2 x 1rem body padding + a 36rem form + the 1rem layout gap + the 14rem help.
$side-by-side-min: 53rem;
.helper-text {
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0em;
text-align: left;
// Was a fixed 645px, one field per row, which ran the form 830px down the page.
.key-result-dialog {
width: clamp($side-by-side-min, 60vw, 58rem);
p {
padding-right: 5px;
@media (max-width: $stack-below) {
width: min(calc(100vw - 2rem), 40rem);
}
}
// `--gauzy-card-1` / `--gauzy-card-2` are undefined in material-light and
// material-dark: `themes.scss` registers those two against Nebular's own parent
// themes, which carry no `gauzy-card-*` keys. A `var()` with no fallback makes
// the browser throw the entire declaration away, so both uses below carry one.
// This stylesheet is shared — `key-result-parameters` points its `styleUrls`
// here and `edit-objective` / `goal-details` `@forward` it — so the fix reaches
// all four dialogs.
:host {
nb-card {
background: var(--gauzy-card-1, var(--card-background-color));
// Fallback required: `--gauzy-card-1` is undefined in material-light and
// material-dark, and a `var()` with no fallback drops the declaration.
background-color: var(--gauzy-card-1, var(--card-background-color));
}
nb-tab,
.content {
background-color: var(--gauzy-card-2, rgba(126, 126, 143, 0.08));
}
nb-tab {
height: 20.75vh;
}
::ng-deep {
nb-progress-bar {
width: 100%;
.progress-container {
height: 10px !important;
}
.progress-value {
span {
display: none;
}
// Title and close on one line; the close used to sit stacked above the title.
nb-card-header.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem $inset;
border-bottom: $divider;
.title {
margin: 0;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: var(--gauzy-text-color-1, inherit);
}
// A real button, so the close is reachable from the keyboard and named for
// a screen reader; the browser's button chrome is stripped so it still
// reads as the bare icon it was.
.cancel {
width: auto;
margin: 0;
padding: 0;
border: 0;
background: none;
line-height: 1;
cursor: pointer;
&:focus-visible {
outline: 2px solid nb-theme(color-primary-default);
outline-offset: 2px;
border-radius: 2px;
}
}
}
}
.info {
color: nb-theme(color-info-default);
nb-card-body {
padding: $inset;
}
.key-result-layout {
display: flex;
align-items: flex-start;
gap: $inset;
@media (max-width: $stack-below) {
flex-direction: column;
align-items: stretch;
}
}
// Name and Description across the grid, then the type's own fields, the two
// people, the deadlines, and the objective switch.
.key-result-form {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.875rem 0.75rem;
flex: 1 1 auto;
min-width: 0;
align-content: start;
@media (max-width: $single-column-below) {
grid-template-columns: minmax(0, 1fr);
}
}
.field {
min-width: 0;
}
.span-2 {
grid-column: span 2;
}
.span-3 {
grid-column: span 3;
// One column below this, so three of them would create implicit columns and
// leave these fields narrow instead of filling the row.
@media (max-width: $single-column-below) {
grid-column: 1 / -1;
}
}
.span-6 {
grid-column: 1 / -1;
}
// The type picker and the aligned-objective picker each render their fields
// inside wrappers of their own. Taking the wrappers out of the box tree makes
// those fields items of this grid, so they pair up with everything else
// instead of stacking one per row.
::ng-deep ga-keyresult-type-select,
::ng-deep ga-keyresult-type-select > div,
::ng-deep ga-goal-level-select,
::ng-deep ga-goal-level-select > div {
display: contents;
}
::ng-deep .kr-field,
::ng-deep .goal-level-field {
min-width: 0;
grid-column: span 3;
@media (max-width: $single-column-below) {
grid-column: 1 / -1;
}
}
// Initial value, target value and unit read as one trio, so the first of
// them starts a row of its own rather than pairing with Type and leaving the
// other two on the next line. A task's project and task pair up the same way.
::ng-deep .kr-field-initial {
grid-column: 1 / span 2;
}
::ng-deep .kr-field-target,
::ng-deep .kr-field-unit {
grid-column: span 2;
}
::ng-deep .kr-field-project {
grid-column: 1 / span 3;
}
@media (max-width: $single-column-below) {
::ng-deep .kr-field-initial,
::ng-deep .kr-field-target,
::ng-deep .kr-field-unit,
::ng-deep .kr-field-project {
grid-column: 1 / -1;
}
}
// The switch and its label on one line, with the rule that separates the key
// result from the objective it can also become.
.toggle-field {
display: flex;
align-items: center;
margin-top: 0.25rem;
padding-top: 0.875rem;
border-top: $divider;
}
// The label now lives inside nb-toggle, where `status="primary"` would
// otherwise paint it with the primary colour at the toggle's own text size.
.toggle-field ::ng-deep nb-toggle .text {
padding-inline-start: 0.625rem;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: var(--gauzy-text-color-1, inherit);
}
// A note beside the form, not a second column of body copy.
.key-result-help {
flex: 0 0 14rem;
align-self: stretch;
padding-inline-start: $inset;
border-inline-start: $divider;
font-size: $control-font-size;
line-height: 1.125rem;
color: $muted;
// `inherit` against the theme's paragraph type, which would set the help at
// 14px inside a 12px column.
::ng-deep p {
margin: 0;
font-size: inherit;
line-height: inherit;
}
::ng-deep b {
color: var(--gauzy-text-color-1, inherit);
font-weight: 600;
}
// Under the form the hairline moves to the top edge; `flex: none` so the
// 14rem basis does not turn into a height in the column.
@media (max-width: $stack-below) {
flex: none;
padding-inline-start: 0;
padding-top: $inset;
border-inline-start: 0;
border-top: $divider;
}
}
::ng-deep label.label {
display: block;
margin: 0 0 0.375rem;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: $muted;
}
// Labels inside the two pickers carry bootstrap's `.mt-3`, 1rem with
// `!important`; on this grid the row gap sets the rhythm.
::ng-deep label.label.mt-3 {
margin-top: 0 !important;
}
input[nbInput],
textarea[nbInput] {
padding: 0.375rem $control-padding-x;
font-size: $control-font-size;
line-height: 1rem;
}
input[nbInput],
::ng-deep input[nbInput] {
height: $control-height;
// `!important` against the shared `dialog()` mixin (`gauzy/_gauzy-dialogs`),
// which pins every input and select in a dialog to a 2.375rem `min-height`
// with `!important` of its own.
min-height: $control-height !important;
padding: 0 $control-padding-x;
font-size: $control-font-size;
}
textarea[nbInput] {
display: block;
height: 4rem;
min-height: 4rem !important;
}
// Both selectors on purpose: the `dialog()` mixin pins
// `nb-select.appearance-outline.status-basic .select-button` at 2.375rem with
// `!important`, so only a selector of its own shape can win, while the bare
// one still reaches a select carrying another status.
::ng-deep nb-select .select-button,
::ng-deep nb-select.appearance-outline.status-basic .select-button {
height: $control-height;
min-height: $control-height !important;
padding-block: 0;
padding-inline-start: $control-padding-x !important;
font-size: $control-font-size;
line-height: 1rem;
}
// The project and task pickers are `ng-select`s, not `nb-select`s.
::ng-deep .ng-select .ng-select-container {
min-height: $control-height;
font-size: $control-font-size;
}
// Where a KPI select goes once a KPI exists: a dashed slot the height of a
// control, rather than a filled primary button in the middle of the form.
::ng-deep .add-inline {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
width: 100%;
height: $control-height;
margin: 0;
padding: 0 $control-padding-x;
border: 1px dashed var(--gauzy-border-default-color, rgba(126, 126, 143, 0.4));
border-radius: var(--gauzy-radius-sm, 0.375rem);
background: transparent;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: nb-theme(color-primary-default);
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
i {
font-size: 10px;
}
&:hover {
border-color: nb-theme(color-primary-default);
background-color: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
}
&:focus-visible {
outline: 2px solid nb-theme(color-primary-default);
outline-offset: 2px;
}
}
// Buttons on the trailing edge, Cancel quiet beside the primary action.
nb-card-footer.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem $inset;
border-top: $divider;
button {
margin: 0;
}
}
}
@@ -45,6 +45,14 @@ export class EditKeyResultsComponent implements OnInit, OnDestroy {
orgName: string;
numberUnitsEnum: string[] = Object.values(KeyResultNumberUnitsEnum);
helperText = '';
/**
* The field the pointer is over, the field holding keyboard focus, and the field the level picker reports
* (it resolves its own hover and focus before emitting). Tracked apart: held in one value, ending any one
* interaction cleared the help while another was still on a field.
*/
private hoveredField = '';
private focusedField = '';
private levelField = '';
teams: IOrganizationTeam[] = [];
hideOrg = false;
hideTeam = false;
@@ -162,6 +170,33 @@ export class EditKeyResultsComponent implements OnInit, OnDestroy {
}
}
/** Records the field the pointer moved onto, or `''` when it left one. */
hoverHelper(field: string) {
this.hoveredField = field;
this.showHelper();
}
/** Records the field that took keyboard focus, or `''` when it lost it. */
focusHelper(field: string) {
this.focusedField = field;
this.showHelper();
}
/** Records whichever of Level or Owner the aligned-objective picker is reporting, or `''` for none. */
levelHelper(field: string) {
this.levelField = field;
this.showHelper();
}
/**
* Picks the help the aside shows. The pointer wins while it is over a field, so hovering one field while
* another holds focus still reads as before; the help falls back to whatever is left and only clears once
* every interaction has ended.
*/
private showHelper() {
this.helperText = this.hoveredField || this.levelField || this.focusedField;
}
selectEmployee(event, control) {
if (control === 'lead') {
this.keyResultsForm.patchValue({ leadId: event });
@@ -1,94 +1,66 @@
<nb-card style="width: 645px; height: 390px">
<nb-card-header>
<span class="cancel"
><i class="fas fa-times" (click)="closeDialog(null)"></i
></span>
<h4 class="title">{{
'KEY_RESULT_PAGE.EDIT_KEY_RESULT_PARAMETERS'
| translate
}}</h4>
</nb-card-header>
<nb-card-body class="p-0">
<nb-tabset>
<nb-tab tabTitle="{{ 'KEY_RESULT_PAGE.FORM.LABELS.WEIGHT' | translate }}">
<p class="info">
{{ 'KEY_RESULT_PAGE.WEIGHT.MESSAGE' | translate }}
</p>
<form [formGroup]="weightForm">
<div class="row mt-2">
<div class="col-md-6">
<label for="key-result-weight" class="label mt-3">
{{
'KEY_RESULT_PAGE.FORM.LABELS.WEIGHT'
| translate
}}
</label>
<nb-select
id="key-result-weight"
fullWidth
formControlName="weight"
>
@for (
weight of keyResultWeightEnum
| keyvalue
; track
weight) {
<nb-option
[value]="weight.value"
>{{
'KEY_RESULT_PAGE.WEIGHT.' + weight.key
| translate
}}</nb-option
>
}
</nb-select>
</div>
<div
class="col-md-6 d-flex flex-column justify-content-around"
>
<label
for="goal-progress-comparison"
class="label mt-3"
>
{{
'KEY_RESULT_PAGE.WEIGHT.OBJECTIVE_PROGRESS'
| translate: { weight: keyResultWeight }
}}
</label>
<span class="w-100 d-flex align-items-center">
<span class="mr-3">{{ keyResultWeight }}%</span>
<nb-progress-bar
id="goal-progress-comparison"
[value]="keyResultWeight"
status="primary"
size="small"
>
</nb-progress-bar>
</span>
</div>
</div>
</form>
</nb-tab>
<nb-tab tabTitle="{{ 'KEY_RESULT_PAGE.FORM.LABELS.TYPE' | translate }}">
<form [formGroup]="typeForm">
<ga-keyresult-type-select
[enableHelperText]="false"
[parentFormGroup]="typeForm"
[orgId]="data.orgId"
[KPIs]="KPIs"
[settings]="data.settings"
[numberUnits]="numberUnitsEnum"
></ga-keyresult-type-select>
</form>
</nb-tab>
</nb-tabset>
</nb-card-body>
<nb-card-footer class="text-left content">
<button class="mr-3" outline nbButton (click)="closeDialog(null)">
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button nbButton status="success" (click)="updateKeyResult()">
{{ 'BUTTONS.SAVE' | translate }}
</button>
</nb-card-footer>
</nb-card>
<nb-card class="parameters-dialog">
<nb-card-header class="header">
<h4 class="title">{{ 'KEY_RESULT_PAGE.EDIT_KEY_RESULT_PARAMETERS' | translate }}</h4>
<button type="button" class="cancel" [attr.aria-label]="'BUTTONS.CLOSE' | translate" (click)="closeDialog(null)">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</nb-card-header>
<nb-card-body>
<nb-tabset>
<nb-tab tabTitle="{{ 'KEY_RESULT_PAGE.FORM.LABELS.WEIGHT' | translate }}">
<!-- What a weight does, as a quiet note rather than two lines of link-coloured text. -->
<p class="hint">{{ 'KEY_RESULT_PAGE.WEIGHT.MESSAGE' | translate }}</p>
<form class="parameters-form" [formGroup]="weightForm">
<div class="field span-3">
<label for="key-result-weight" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.WEIGHT' | translate }}
</label>
<nb-select id="key-result-weight" fullWidth formControlName="weight">
@for (weight of keyResultWeightEnum | keyvalue; track weight) {
<nb-option [value]="weight.value">
{{ 'KEY_RESULT_PAGE.WEIGHT.' + weight.key | translate }}
</nb-option>
}
</nb-select>
</div>
<!-- What that weight comes to against the objective's other key results. -->
<div class="field span-3 share">
<label for="goal-progress-comparison" class="label">
{{ 'KEY_RESULT_PAGE.WEIGHT.OBJECTIVE_PROGRESS' | translate : { weight: keyResultWeight } }}
</label>
<div class="share-row">
<span class="share-value">{{ keyResultWeight }}%</span>
<nb-progress-bar
class="share-bar"
id="goal-progress-comparison"
[value]="keyResultWeight"
status="primary"
size="tiny"
>
</nb-progress-bar>
</div>
</div>
</form>
</nb-tab>
<nb-tab tabTitle="{{ 'KEY_RESULT_PAGE.FORM.LABELS.TYPE' | translate }}">
<form class="parameters-form" [formGroup]="typeForm">
<ga-keyresult-type-select
[parentFormGroup]="typeForm"
[orgId]="data.orgId"
[KPIs]="KPIs"
[settings]="data.settings"
[numberUnits]="numberUnitsEnum"
></ga-keyresult-type-select>
</form>
</nb-tab>
</nb-tabset>
</nb-card-body>
<nb-card-footer class="footer">
<button nbButton ghost status="basic" size="small" (click)="closeDialog(null)">
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button nbButton status="success" size="small" (click)="updateKeyResult()">
{{ 'BUTTONS.SAVE' | translate }}
</button>
</nb-card-footer>
</nb-card>
@@ -0,0 +1,295 @@
@use 'gauzy/_gauzy-dialogs' as *;
// The Edit Key Result Parameters dialog, on the same scale as the other goals
// dialogs: a header row, 12px tabs over a panel that sizes to its content, 12px
// labels over 2rem controls, and a footer whose buttons sit on the trailing
// edge. It used to borrow the Edit Key Result stylesheet, which dressed none of
// this. Every custom property carries a fallback — `--gauzy-*` is undefined in
// material-light / material-dark, where a `var()` without one is dropped.
$control-height: 2rem;
$control-font-size: 12px;
$control-padding-x: 0.75rem;
$inset: 1rem;
$divider: 1px solid var(--gauzy-border-default-color, rgba(126, 126, 143, 0.18));
$muted: var(--gauzy-text-color-2, rgba(126, 126, 143, 1));
$single-column-below: 575.98px;
// Was a hard 645 x 390px, which left a third of the sheet empty under two
// fields and clipped the Type tab's own.
.parameters-dialog {
width: min(calc(100vw - 2rem), 40rem);
max-height: calc(100vh - 4rem);
}
:host {
nb-card {
background-color: var(--gauzy-card-1, var(--card-background-color));
}
nb-card-header.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem $inset;
border-bottom: $divider;
.title {
margin: 0;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: var(--gauzy-text-color-1, inherit);
}
// A real button, so the close is reachable from the keyboard and named for
// a screen reader; the browser's button chrome is stripped.
.cancel {
width: auto;
margin: 0;
padding: 0;
border: 0;
background: none;
line-height: 1;
cursor: pointer;
&:focus-visible {
outline: 2px solid nb-theme(color-primary-default);
outline-offset: 2px;
border-radius: 2px;
}
}
}
nb-card-body {
padding: 0;
}
// Tabs at 12px; the active one keeps the app's tinted tab, which joins the
// panel under it.
nb-tabset {
::ng-deep .tabset {
padding: 0 $inset;
border-bottom: $divider;
}
::ng-deep .tab-link,
::ng-deep .tab-text {
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
text-transform: none;
}
::ng-deep .tab-link {
padding: 0.625rem 0.875rem;
}
}
// A floor rather than a fixed height, so the Weight tab does not leave a
// third of the dialog empty and the Type tab is never cut off. No `display`
// here: Nebular hides an inactive tab with `display: none` on this element,
// and overriding it showed both tabs' fields at once.
nb-tab {
min-height: 9rem;
padding: $inset;
background-color: var(--gauzy-card-2, rgba(126, 126, 143, 0.06));
}
// Was `.info`, painted in the info accent: a two-line paragraph louder than
// the field it explains.
.hint {
margin: 0 0 0.875rem;
font-size: $control-font-size;
line-height: 1.125rem;
color: $muted;
}
.parameters-form {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.875rem 0.75rem;
align-items: start;
@media (max-width: $single-column-below) {
grid-template-columns: minmax(0, 1fr);
}
}
.field {
min-width: 0;
}
.span-3 {
grid-column: span 3;
@media (max-width: $single-column-below) {
grid-column: 1 / -1;
}
}
// The type picker renders its fields inside wrappers of its own; taking those
// out of the box tree makes the fields items of this grid.
::ng-deep ga-keyresult-type-select,
::ng-deep ga-keyresult-type-select > div {
display: contents;
}
::ng-deep .kr-field {
min-width: 0;
grid-column: span 3;
@media (max-width: $single-column-below) {
grid-column: 1 / -1;
}
}
// The value trio on a row of its own, under Type, as in the Edit Key Result
// dialog; a task's project and task pair up the same way.
::ng-deep .kr-field-initial {
grid-column: 1 / span 2;
}
::ng-deep .kr-field-target,
::ng-deep .kr-field-unit {
grid-column: span 2;
}
::ng-deep .kr-field-project {
grid-column: 1 / span 3;
}
@media (max-width: $single-column-below) {
::ng-deep .kr-field-initial,
::ng-deep .kr-field-target,
::ng-deep .kr-field-unit,
::ng-deep .kr-field-project {
grid-column: 1 / -1;
}
}
// The share this weight comes to, as a figure with a bar under its label
// rather than a bar stretched across half the dialog.
.share-row {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: $control-height;
}
.share-value {
font-size: 0.875rem;
font-weight: 600;
line-height: 1rem;
color: var(--gauzy-text-color-1, inherit);
}
.share-bar {
flex: 1 1 auto;
width: 100%;
::ng-deep .progress-container {
height: 6px !important;
border-radius: 999px;
background-color: var(--gauzy-border-default-color, rgba(126, 126, 143, 0.18));
}
::ng-deep .progress-value {
border-radius: 999px;
span {
display: none;
}
}
}
::ng-deep label.label {
display: block;
margin: 0 0 0.375rem;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: $muted;
}
::ng-deep label.label.mt-3 {
margin-top: 0 !important;
}
::ng-deep input[nbInput] {
height: $control-height;
// `!important` against the shared `dialog()` mixin, which pins every input
// and select in a dialog to a 2.375rem `min-height` with `!important`.
min-height: $control-height !important;
padding: 0 $control-padding-x;
font-size: $control-font-size;
line-height: 1rem;
}
// Both selectors on purpose: the `dialog()` mixin pins the `.status-basic`
// one with `!important`, so only a selector of its shape can win, while the
// bare one still reaches a select carrying another status.
::ng-deep nb-select .select-button,
::ng-deep nb-select.appearance-outline.status-basic .select-button {
height: $control-height;
min-height: $control-height !important;
padding-block: 0;
padding-inline-start: $control-padding-x !important;
font-size: $control-font-size;
line-height: 1rem;
}
::ng-deep .ng-select .ng-select-container {
min-height: $control-height;
font-size: $control-font-size;
}
// Where a KPI select goes once a KPI exists: a dashed slot the height of a
// control, rather than a filled primary button in the middle of the form.
::ng-deep .add-inline {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
width: 100%;
height: $control-height;
margin: 0;
padding: 0 $control-padding-x;
border: 1px dashed var(--gauzy-border-default-color, rgba(126, 126, 143, 0.4));
border-radius: var(--gauzy-radius-sm, 0.375rem);
background: transparent;
font-size: $control-font-size;
font-weight: 600;
line-height: 1rem;
color: nb-theme(color-primary-default);
cursor: pointer;
i {
font-size: 10px;
}
&:hover {
border-color: nb-theme(color-primary-default);
background-color: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
}
&:focus-visible {
outline: 2px solid nb-theme(color-primary-default);
outline-offset: 2px;
}
}
// Buttons on the trailing edge, Cancel quiet beside the primary action.
nb-card-footer.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem $inset;
border-top: $divider;
button {
margin: 0;
}
}
}
@@ -18,7 +18,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
@Component({
selector: 'ga-key-result-parameters',
templateUrl: './key-result-parameters.component.html',
styleUrls: ['../edit-keyresults/edit-keyresults.component.scss'],
styleUrls: ['./key-result-parameters.component.scss'],
standalone: false
})
export class KeyResultParametersComponent implements OnInit, OnDestroy {
@@ -1,126 +1,97 @@
<div [formGroup]="parentFormGroup">
<div [class.row]="enableHelperText">
<div [class.col-md-7]="enableHelperText">
<label for="key-result-type" class="label mt-3">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.KEY_RESULT_TYPE' | translate }}
</label>
<nb-select (selectedChange)="taskTypeValidators()" id="key-result-type" fullWidth formControlName="type">
@for (type of keyResultTypeEnum | keyvalue; track type.key) {
<nb-option [hidden]="isTypeHidden(type.value)" [value]="type.value">
{{ 'KEY_RESULT_PAGE.TYPE.' + type.key | translate }}
</nb-option>
}
</nb-select>
</div>
@if (enableHelperText) {
<div class="col-md-5 position-relative mt-3"></div>
}
<!-- The type, and whatever that type needs, as sibling fields: each carries `kr-field` so the dialog around
them can place them on its own grid. They used to sit one per bootstrap row, each with an empty
five-column spacer beside it, which is what made this picker a stack of half-width rows. -->
<div class="kr-type-fields" [formGroup]="parentFormGroup">
<div class="kr-field kr-field-type">
<label for="key-result-type" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.KEY_RESULT_TYPE' | translate }}
</label>
<nb-select (selectedChange)="taskTypeValidators()" id="key-result-type" fullWidth formControlName="type">
@for (type of keyResultTypeEnum | keyvalue; track type.key) {
<nb-option [hidden]="isTypeHidden(type.value)" [value]="type.value">
{{ 'KEY_RESULT_PAGE.TYPE.' + type.key | translate }}
</nb-option>
}
</nb-select>
</div>
@if (parentFormGroup.value.type === keyResultTypeEnum.TASK) {
<div [class.row]="enableHelperText">
<div [class.col-md-7]="enableHelperText">
<div class="row">
<div class="col-md-12">
<label class="label mt-3" for="project-selector">{{
'KEY_RESULT_PAGE.FORM.LABELS.SELECT_PROJECT' | translate
}}</label>
<ga-project-selector id="project-selector" formControlName="projectId"> </ga-project-selector>
</div>
<div class="col-md-12">
<label class="label mt-3" for="task-selector">{{
'KEY_RESULT_PAGE.FORM.LABELS.SELECT_TASK' | translate
}}</label>
<ga-task-selector
id="task-selector"
formControlName="taskId"
[projectId]="parentFormGroup.value.projectId"
>
</ga-task-selector>
</div>
</div>
</div>
@if (enableHelperText) {
<div class="col-md-5 position-relative mt-3"></div>
}
<div class="kr-field kr-field-project">
<label class="label" for="project-selector">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.SELECT_PROJECT' | translate }}
</label>
<ga-project-selector id="project-selector" formControlName="projectId"></ga-project-selector>
</div>
<div class="kr-field kr-field-task">
<label class="label" for="task-selector">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.SELECT_TASK' | translate }}
</label>
<ga-task-selector
id="task-selector"
formControlName="taskId"
[projectId]="parentFormGroup.value.projectId"
></ga-task-selector>
</div>
}
<div [class.row]="enableHelperText">
<div [class.col-md-7]="enableHelperText">
@if (parentFormGroup.value.type === keyResultTypeEnum.KPI) {
<div>
<label for="kpi" class="label mt-3">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.SELECT_KPI' | translate }}
</label>
@if (KPIs.length > 0) {
<nb-select id="kpi" fullWidth formControlName="kpiId">
@for (kpi of KPIs; track kpi.id) {
<nb-option [value]="kpi.id">
{{ kpi.name }}
</nb-option>
}
</nb-select>
} @else {
<p>
<button nbButton status="primary" id="kpi" (click)="openEditKPI()">
{{ 'BUTTONS.ADD_KPI' | translate }}
</button>
</p>
}
</div>
@if (parentFormGroup.value.type === keyResultTypeEnum.KPI) {
<div class="kr-field kr-field-kpi">
<label for="kpi" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.SELECT_KPI' | translate }}
</label>
@if (KPIs?.length) {
<nb-select id="kpi" fullWidth formControlName="kpiId">
@for (kpi of KPIs; track kpi.id) {
<nb-option [value]="kpi.id">
{{ kpi.name }}
</nb-option>
}
</div>
@if (enableHelperText) {
<div class="col-md-5 position-relative mt-3"></div>
</nb-select>
} @else {
<!-- No KPI to pick yet: an empty slot where the select will be, rather than a filled primary button. -->
<button type="button" id="kpi" class="add-inline" (click)="openEditKPI()">
<i class="fas fa-plus" aria-hidden="true"></i>
{{ 'BUTTONS.ADD_KPI' | translate }}
</button>
}
</div>
}
@if ( parentFormGroup.value.type === keyResultTypeEnum.NUMERICAL || parentFormGroup.value.type ===
keyResultTypeEnum.CURRENCY ) {
<div [class.row]="enableHelperText">
<div [class.col-md-7]="enableHelperText">
<div class="row mt-2">
<div class="col-md-4">
<label for="initial-value" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.INITIAL_VALUE' | translate }}
</label>
<input
type="number"
[min]="0"
id="initial-value"
nbInput
fullWidth
formControlName="initialValue"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.INITIAL_VALUE' | translate }}"
/>
</div>
<div class="col-md-4">
<label for="target-value" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.TARGET_VALUE' | translate }}
</label>
<input
class="d-flex space-between"
type="number"
id="target-value"
nbInput
[min]="0"
fullWidth
formControlName="targetValue"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.TARGET_VALUE' | translate }}"
/>
</div>
<div class="col-md-4">
<ga-goal-custom-unit-select
[parentFormGroup]="parentFormGroup"
[numberUnits]="numberUnits"
></ga-goal-custom-unit-select>
</div>
</div>
</div>
@if (enableHelperText) {
<div class="col-md-5 position-relative mt-3"></div>
}
<div class="kr-field kr-field-initial">
<label for="initial-value" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.INITIAL_VALUE' | translate }}
</label>
<input
type="number"
[min]="0"
id="initial-value"
nbInput
fullWidth
formControlName="initialValue"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.INITIAL_VALUE' | translate }}"
/>
</div>
<div class="kr-field kr-field-target">
<label for="target-value" class="label">
{{ 'KEY_RESULT_PAGE.FORM.LABELS.TARGET_VALUE' | translate }}
</label>
<input
type="number"
id="target-value"
nbInput
[min]="0"
fullWidth
formControlName="targetValue"
placeholder="{{ 'KEY_RESULT_PAGE.FORM.LABELS.TARGET_VALUE' | translate }}"
/>
</div>
<div class="kr-field kr-field-unit">
<ga-goal-custom-unit-select
[parentFormGroup]="parentFormGroup"
[numberUnits]="numberUnits"
></ga-goal-custom-unit-select>
</div>
}
</div>
@@ -18,7 +18,6 @@ export class KeyresultTypeSelectComponent {
@Input() orgId: string;
@Input() KPIs: Array<IKPI>;
@Input() numberUnits: string[];
@Input() enableHelperText = true;
keyResultTypeEnum = KeyResultTypeEnum;