feat(web): settings by sections, with a menu
The settings page was one long strip of panels of uneven widths. It has a menu now (General, Flujo de trabajo, Tickets, Equipos, Automatizaciones, Integraciones, Importar y exportar, Zona peligrosa), one section at a time and in the URL (?seccion=), each panel full width. A dot marks the sections with unsaved changes, and the save bar follows whichever section is open. On a phone the menu is a row of chips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -43,6 +44,77 @@ const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
/** Edits the settings draft (saved with the bar below). */
|
||||
draft?: boolean;
|
||||
/** Only for who can manage the project (or its owner). */
|
||||
needs?: 'manage' | 'owner';
|
||||
}
|
||||
|
||||
const SECTIONS: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
hint: 'Quién ve el proyecto y si el público puede proponer cosas.',
|
||||
},
|
||||
{
|
||||
id: 'flujo',
|
||||
draft: true,
|
||||
label: 'Flujo de trabajo',
|
||||
hint: 'Las columnas del tablero, a cuáles se puede pasar desde cada una y cuándo algo está hecho.',
|
||||
},
|
||||
{
|
||||
id: 'tickets',
|
||||
draft: true,
|
||||
label: 'Tickets',
|
||||
hint: 'Tipos, áreas y campos propios. Lo que ya se usa no se borra: se archiva y deja de ofrecerse.',
|
||||
},
|
||||
{
|
||||
id: 'equipos',
|
||||
draft: true,
|
||||
label: 'Equipos',
|
||||
hint: 'Grupos de miembros, para asignarles tickets y avisarles con «@equipo».',
|
||||
},
|
||||
{
|
||||
id: 'automatizaciones',
|
||||
label: 'Automatizaciones',
|
||||
hint: 'Reglas «cuando… si… entonces…» que hacen solas el trabajo repetido.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'integraciones',
|
||||
label: 'Integraciones',
|
||||
hint: 'Commits y pull requests de Git en cada ticket, y avisos a otros sistemas.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'datos',
|
||||
label: 'Importar y exportar',
|
||||
hint: 'Los tickets en CSV: sacarlos, o traerlos de TaskStream o de Jira.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'peligro',
|
||||
label: 'Zona peligrosa',
|
||||
hint: 'Archivar el proyecto o eliminarlo del todo.',
|
||||
needs: 'owner',
|
||||
},
|
||||
];
|
||||
|
||||
/** Which section holds each part of the draft. */
|
||||
const DRAFT_SECTION: Partial<Record<keyof ProjectSettings, string>> = {
|
||||
statuses: 'flujo',
|
||||
transitions: 'flujo',
|
||||
checklistBeforeDone: 'flujo',
|
||||
issueTypes: 'tickets',
|
||||
areas: 'tickets',
|
||||
customFields: 'tickets',
|
||||
teams: 'equipos',
|
||||
};
|
||||
|
||||
/** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */
|
||||
function idFrom(name: string): string {
|
||||
const slug = name
|
||||
@@ -79,29 +151,45 @@ function idFrom(name: string): string {
|
||||
PiNoteComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<div>
|
||||
<h2>Configuración</h2>
|
||||
<p>
|
||||
Estados, transiciones, tipos de ticket, áreas y campos de este
|
||||
proyecto. Lo que ya se usa no se borra: se archiva y deja de
|
||||
ofrecerse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ts-settings-layout">
|
||||
<nav class="ts-settings-nav" aria-label="Secciones de la configuración">
|
||||
<p class="ts-settings-nav__title">Configuración</p>
|
||||
@for (item of sections(); track item.id) {
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ seccion: item.id }"
|
||||
[class.is-active]="item.id === current().id"
|
||||
[class.is-danger]="item.id === 'peligro'"
|
||||
[attr.aria-current]="item.id === current().id ? 'page' : null"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
@if (changed().has(item.id)) {
|
||||
<span class="ts-settings-nav__dot" title="Cambios sin guardar"
|
||||
><span class="visually-hidden">(cambios sin guardar)</span></span
|
||||
>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (!facade.canManage()) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="info"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="Solo lectura"
|
||||
text="Solo el propietario y los admins pueden cambiar la configuración."
|
||||
/>
|
||||
}
|
||||
<div class="ts-settings-main">
|
||||
<header class="ts-settings-head">
|
||||
<h2>{{ current().label }}</h2>
|
||||
<p>{{ current().hint }}</p>
|
||||
</header>
|
||||
|
||||
@if (facade.project(); as project) {
|
||||
@if (!facade.canManage()) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="info"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="Solo lectura"
|
||||
text="Solo el propietario y los admins pueden cambiar la configuración."
|
||||
/>
|
||||
}
|
||||
|
||||
@if (current().id === 'general' && facade.project(); as project) {
|
||||
<section
|
||||
class="ts-panel ts-block ts-visibility"
|
||||
aria-labelledby="vis-title"
|
||||
@@ -172,12 +260,15 @@ function idFrom(name: string): string {
|
||||
}
|
||||
|
||||
@if (draft(); as d) {
|
||||
@if (current().draft) {
|
||||
<form
|
||||
class="ts-settings"
|
||||
(submit)="$event.preventDefault(); save()"
|
||||
novalidate
|
||||
>
|
||||
<fieldset [disabled]="!facade.canManage()">
|
||||
@switch (current().id) {
|
||||
@case ('flujo') {
|
||||
<!-- Statuses -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="st-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -357,7 +448,35 @@ function idFrom(name: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ts-settings__pair">
|
||||
<!-- Definition of done -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="dod-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="dod-title">Definición de «Hecho»</h3>
|
||||
<p class="ts-muted">
|
||||
Un ticket no pasa a un estado «Hecho» con su checklist a
|
||||
medias. El propietario y los admins sí pueden cerrarlo.
|
||||
</p>
|
||||
</div>
|
||||
<label class="ts-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
[checked]="!!d.checklistBeforeDone"
|
||||
(change)="
|
||||
setChecklistBeforeDone($any($event.target).checked)
|
||||
"
|
||||
/>
|
||||
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||
<span>{{
|
||||
d.checklistBeforeDone ? 'Activada' : 'Desactivada'
|
||||
}}</span>
|
||||
</label>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
}
|
||||
@case ('tickets') {
|
||||
<!-- Ticket types -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="ty-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -530,90 +649,6 @@ function idFrom(name: string): string {
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Teams -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="teams-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="teams-title">Equipos</h3>
|
||||
<p class="ts-muted">
|
||||
Grupos de miembros: los tickets se pueden asignar a uno y
|
||||
«@equipo» avisa a todos sus miembros.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ts-add" (click)="addTeam()">
|
||||
+ Añadir equipo
|
||||
</button>
|
||||
</header>
|
||||
@if (!d.teams.length) {
|
||||
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
|
||||
}
|
||||
<ol class="ts-rows">
|
||||
@for (team of d.teams; track $index; let i = $index) {
|
||||
<li
|
||||
class="ts-row ts-field-grid"
|
||||
[class.is-archived]="team.archived"
|
||||
>
|
||||
<span class="ts-name">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="40"
|
||||
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
|
||||
[value]="team.name"
|
||||
(input)="setName('teams', i, $any($event.target).value)"
|
||||
/>
|
||||
<code class="ts-id">{{ team.id }}</code>
|
||||
</span>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
section: 'teams',
|
||||
index: i,
|
||||
item: team,
|
||||
}"
|
||||
/>
|
||||
<div class="ts-team-members ts-field-type">
|
||||
@for (member of facade.members(); track member.user.id) {
|
||||
<pi-checkbox
|
||||
[label]="member.user.name"
|
||||
[checked]="team.memberIds.includes(member.user.id)"
|
||||
(checkedChange)="
|
||||
setTeamMember(i, member.user.id, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Definition of done -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="dod-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="dod-title">Definición de «Hecho»</h3>
|
||||
<p class="ts-muted">
|
||||
Un ticket no pasa a un estado «Hecho» con su checklist a
|
||||
medias. El propietario y los admins sí pueden cerrarlo.
|
||||
</p>
|
||||
</div>
|
||||
<label class="ts-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
[checked]="!!d.checklistBeforeDone"
|
||||
(change)="
|
||||
setChecklistBeforeDone($any($event.target).checked)
|
||||
"
|
||||
/>
|
||||
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||
<span>{{
|
||||
d.checklistBeforeDone ? 'Activada' : 'Desactivada'
|
||||
}}</span>
|
||||
</label>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -699,57 +734,94 @@ function idFrom(name: string): string {
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@case ('equipos') {
|
||||
<!-- Teams -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="teams-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="teams-title">Equipos</h3>
|
||||
<p class="ts-muted">
|
||||
Grupos de miembros: los tickets se pueden asignar a uno y
|
||||
«@equipo» avisa a todos sus miembros.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ts-add" (click)="addTeam()">
|
||||
+ Añadir equipo
|
||||
</button>
|
||||
</header>
|
||||
@if (!d.teams.length) {
|
||||
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
|
||||
}
|
||||
<ol class="ts-rows">
|
||||
@for (team of d.teams; track $index; let i = $index) {
|
||||
<li
|
||||
class="ts-row ts-field-grid"
|
||||
[class.is-archived]="team.archived"
|
||||
>
|
||||
<span class="ts-name">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="40"
|
||||
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
|
||||
[value]="team.name"
|
||||
(input)="setName('teams', i, $any($event.target).value)"
|
||||
/>
|
||||
<code class="ts-id">{{ team.id }}</code>
|
||||
</span>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
section: 'teams',
|
||||
index: i,
|
||||
item: team,
|
||||
}"
|
||||
/>
|
||||
<div class="ts-team-members ts-field-type">
|
||||
@for (member of facade.members(); track member.user.id) {
|
||||
<pi-checkbox
|
||||
[label]="member.user.name"
|
||||
[checked]="team.memberIds.includes(member.user.id)"
|
||||
(checkedChange)="
|
||||
setTeamMember(i, member.user.id, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
}
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
@if (facade.canManage() && (dirty() || facade.savingSettings())) {
|
||||
<footer class="ts-savebar" [class.is-dirty]="dirty()">
|
||||
<div class="ts-savebar__status" aria-live="polite">
|
||||
@if (problems().length) {
|
||||
<ul class="ts-problems">
|
||||
@for (problem of problems(); track problem) {
|
||||
<li>
|
||||
<pi-icon icon="exclamation" size="x-small" />
|
||||
{{ problem }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<span>Cambios sin guardar</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-secondary"
|
||||
[disabled]="!dirty() || facade.savingSettings()"
|
||||
(click)="discard()"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="ts-primary"
|
||||
[disabled]="
|
||||
!dirty() || !!problems().length || facade.savingSettings()
|
||||
"
|
||||
>
|
||||
{{ facade.savingSettings() ? 'Guardando…' : 'Guardar cambios' }}
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</form>
|
||||
|
||||
@if (facade.canManage() && facade.project(); as project) {
|
||||
<ts-git-integration
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-automations [projectId]="project.id" />
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
@if (facade.canManage() && facade.project(); as project) {
|
||||
@switch (current().id) {
|
||||
@case ('automatizaciones') {
|
||||
<ts-automations [projectId]="project.id" />
|
||||
}
|
||||
@case ('integraciones') {
|
||||
<ts-git-integration
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
}
|
||||
@case ('datos') {
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (
|
||||
current().id === 'peligro' && facade.isOwner() && facade.project();
|
||||
as project
|
||||
) {
|
||||
<section
|
||||
class="ts-panel ts-block ts-visibility"
|
||||
aria-labelledby="archive-title"
|
||||
@@ -817,6 +889,46 @@ function idFrom(name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
<!-- After whichever section is open: the draft spans several. -->
|
||||
@if (facade.canManage() && (dirty() || facade.savingSettings())) {
|
||||
<footer class="ts-savebar" [class.is-dirty]="dirty()">
|
||||
<div class="ts-savebar__status" aria-live="polite">
|
||||
@if (problems().length) {
|
||||
<ul class="ts-problems">
|
||||
@for (problem of problems(); track problem) {
|
||||
<li>
|
||||
<pi-icon icon="exclamation" size="x-small" />
|
||||
{{ problem }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<span>Cambios sin guardar</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-secondary"
|
||||
[disabled]="!dirty() || facade.savingSettings()"
|
||||
(click)="discard()"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-primary"
|
||||
(click)="save()"
|
||||
[disabled]="
|
||||
!dirty() || !!problems().length || facade.savingSettings()
|
||||
"
|
||||
>
|
||||
{{ facade.savingSettings() ? 'Guardando…' : 'Guardar cambios' }}
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
#archive
|
||||
let-section="section"
|
||||
@@ -856,8 +968,123 @@ function idFrom(name: string): string {
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
/* A menu of sections on the left, one section at a time on the right. */
|
||||
.ts-settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.ts-settings-nav {
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.625rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--ts-ink) 6%, transparent);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
&.is-active {
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--ts-brand);
|
||||
color: var(--ts-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.is-danger {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--ts-c-red);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-settings-nav__title {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-settings-nav__dot {
|
||||
flex: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-c-amber);
|
||||
}
|
||||
.ts-settings-main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-settings-head {
|
||||
margin-bottom: 0.25rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
p {
|
||||
max-width: 46rem;
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.ts-settings-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
/* The menu becomes a row of chips that scrolls sideways. */
|
||||
.ts-settings-nav {
|
||||
position: static;
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
a {
|
||||
flex: none;
|
||||
padding: 0.4375rem 0.875rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
&.is-active {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
&.is-danger {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-settings-nav__title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.ts-block.ts-visibility,
|
||||
.ts-block.ts-danger {
|
||||
@@ -978,13 +1205,8 @@ function idFrom(name: string): string {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.ts-settings__pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.ts-block {
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
@@ -1053,8 +1275,8 @@ function idFrom(name: string): string {
|
||||
color: var(--ts-ink) !important;
|
||||
}
|
||||
}
|
||||
/* Two lines, to fit a panel of the pair: name and archive on top;
|
||||
icon, color and template below. */
|
||||
/* Two lines in a narrow panel: name and archive on top; icon, color
|
||||
and template below. One line when it fits (below). */
|
||||
.ts-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 6.5rem;
|
||||
@@ -1083,6 +1305,30 @@ function idFrom(name: string): string {
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
@container (min-width: 46rem) {
|
||||
.ts-type-grid {
|
||||
grid-template-columns: 2rem minmax(8rem, 1fr) 8rem 8rem auto 6.5rem;
|
||||
> .ts-name {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
> select:nth-of-type(1) {
|
||||
grid-area: 1 / 3;
|
||||
}
|
||||
> select:nth-of-type(2) {
|
||||
grid-area: 1 / 4;
|
||||
}
|
||||
> .ts-template-toggle {
|
||||
grid-area: 1 / 5;
|
||||
}
|
||||
> .ts-archive,
|
||||
> .ts-icon-button {
|
||||
grid-area: 1 / 6;
|
||||
}
|
||||
> .ts-template {
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-area-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
|
||||
@@ -1364,6 +1610,37 @@ export class SettingsPage {
|
||||
protected readonly icons = TYPE_ICONS;
|
||||
protected readonly fieldTypes = FIELD_TYPES;
|
||||
|
||||
/** `?seccion=` (`withComponentInputBinding`). */
|
||||
readonly seccion = input<string | undefined>();
|
||||
|
||||
protected readonly sections = computed(() =>
|
||||
SECTIONS.filter(
|
||||
(s) =>
|
||||
!s.needs ||
|
||||
(s.needs === 'manage' && this.facade.canManage()) ||
|
||||
(s.needs === 'owner' && this.facade.isOwner()),
|
||||
),
|
||||
);
|
||||
protected readonly current = computed(
|
||||
() =>
|
||||
this.sections().find((s) => s.id === this.seccion()) ??
|
||||
this.sections()[0],
|
||||
);
|
||||
|
||||
/** Sections with unsaved changes, marked in the menu. */
|
||||
protected readonly changed = computed(() => {
|
||||
const draft = this.draft();
|
||||
const saved = this.saved();
|
||||
const changed = new Set<string>();
|
||||
if (!draft || !saved) return changed;
|
||||
for (const [key, section] of Object.entries(DRAFT_SECTION)) {
|
||||
const k = key as keyof ProjectSettings;
|
||||
if (JSON.stringify(draft[k]) !== JSON.stringify(saved[k]))
|
||||
changed.add(section);
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
|
||||
/** What the API has; the draft is compared against it. */
|
||||
private readonly saved = computed(
|
||||
() => this.facade.project()?.settings ?? null,
|
||||
|
||||
Reference in New Issue
Block a user