feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s
The lesson page gains an "Instance of" panel that holds one of three answers. Unjudged is the fall-through, so it is stated rather than left blank: - the rule(s) it was judged an instance of; - "No rule — <why>"; - "Not yet judged". Suggested links show what they rest on (distinct situations, and projects when more than one), with Confirm / Not an instance for a reader who can write. Rejected links stay listed with their reason. The rule slide-over lists the lessons that are instances of it, plus the suggestions waiting on a judgment. Each entry links through to the other record, and kind and state wear the existing .rule-chip. The write check moves to utils/permission.ts. The copy on the snippet page looked for "edit", which the server never sends, so shared editors saw a read-only page (#4640 "The snippet page hid its edit controls from shared editors"). Guards pin the client's unions and write levels to the service's, the model's and access.py's own values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,34 @@
|
|||||||
import type { RecordUsage } from "@/types/usage";
|
import type { RecordUsage } from "@/types/usage";
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
import type { RuleKind } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
/** Where a lesson stands on "which rule is this an instance of?"
|
||||||
|
* (`services/lesson_rules.py`: LINKED / NO_RULE / UNJUDGED). Three answers,
|
||||||
|
* not two: "looked at and found to stand alone" and "nobody has looked" are
|
||||||
|
* different facts, and the second is the one worth acting on. A rejected link
|
||||||
|
* alone leaves a lesson unjudged — "not that rule" does not say whether
|
||||||
|
* another one fits. */
|
||||||
|
export type RuleJudgment = "linked" | "no_rule" | "unjudged";
|
||||||
|
|
||||||
|
/** A link's state (`models/lesson_rule_link.py` LINK_STATES). */
|
||||||
|
export type LinkState = "suggested" | "confirmed" | "rejected";
|
||||||
|
|
||||||
|
/** One link from a lesson to a rule, in the reader's view: only rules the
|
||||||
|
* reader owns are listed (`rules_for_lessons`). */
|
||||||
|
export interface LessonRuleLink {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
kind: RuleKind;
|
||||||
|
/** `suggested` carries nothing in retrieval until it is judged; only
|
||||||
|
* `confirmed` changes what surfaces; `rejected` is kept so the pair is
|
||||||
|
* never proposed again. */
|
||||||
|
state: LinkState;
|
||||||
|
/** Why it was confirmed or rejected. */
|
||||||
|
note: string;
|
||||||
|
/** What a suggestion rests on — sent on suggested links only. */
|
||||||
|
evidence?: { situations: number; projects: number; co_surfaced: number };
|
||||||
|
}
|
||||||
|
|
||||||
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
|
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
|
||||||
* to rather than by its topic.
|
* to rather than by its topic.
|
||||||
@@ -51,6 +79,13 @@ export interface Lesson {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
systems?: { id: number; name: string }[];
|
systems?: { id: number; name: string }[];
|
||||||
usage?: RecordUsage;
|
usage?: RecordUsage;
|
||||||
|
/** The rules this lesson points at, confirmed first, then suggested, then
|
||||||
|
* rejected. Absent (with `rule_judgment`) when the links could not be read
|
||||||
|
* — which is "not attached", never "no rule". */
|
||||||
|
rules?: LessonRuleLink[];
|
||||||
|
rule_judgment?: RuleJudgment;
|
||||||
|
/** The "no rule fits" answer, present when that is the judgment. */
|
||||||
|
no_rule?: { why: string; judged_at: string | null };
|
||||||
/** Set when another user owns this record. */
|
/** Set when another user owns this record. */
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
owner?: string | null;
|
owner?: string | null;
|
||||||
@@ -128,6 +163,17 @@ export function updateLesson(
|
|||||||
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
|
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confirm or reject one lesson→rule link. Confirming also clears a "no rule
|
||||||
|
* fits" answer: the two cannot both be the current answer. */
|
||||||
|
export function judgeLessonLink(
|
||||||
|
lessonId: number,
|
||||||
|
ruleId: number,
|
||||||
|
verdict: "confirm" | "reject",
|
||||||
|
note = "",
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiPut(`/api/lessons/${lessonId}/rules/${ruleId}`, { verdict, note });
|
||||||
|
}
|
||||||
|
|
||||||
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
|
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
|
||||||
* established shape here (snippets delete the same way): the batch id is in
|
* established shape here (snippets delete the same way): the batch id is in
|
||||||
* the response, but no caller has needed it and inventing a second delete
|
* the response, but no caller has needed it and inventing a second delete
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RecordUsage } from "@/types/usage";
|
import type { RecordUsage } from "@/types/usage";
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
import type { LinkState } from "@/api/lessons";
|
||||||
|
|
||||||
/** How a rule reaches a session (milestone 307). */
|
/** How a rule reaches a session (milestone 307). */
|
||||||
|
|
||||||
@@ -82,6 +83,10 @@ export interface Rule {
|
|||||||
/** Present only when the rule has them (the server omits empty keys). */
|
/** Present only when the rule has them (the server omits empty keys). */
|
||||||
systems?: { id: number; name: string }[];
|
systems?: { id: number; name: string }[];
|
||||||
relations?: RuleRelation[];
|
relations?: RuleRelation[];
|
||||||
|
/** The lessons that point at this rule (milestone 440) — the concrete
|
||||||
|
* situations judged instances of it, plus any suggested and awaiting a
|
||||||
|
* judgment. Readable lessons only; omitted when there are none. */
|
||||||
|
lessons?: { id: number; title: string; state: LinkState; note: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const expiresWhen = ref("");
|
|||||||
|
|
||||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||||
|
|
||||||
|
// The lessons that point at this rule (milestone 440): confirmed instances
|
||||||
|
// first, then suggestions waiting on a judgment. A rejected lesson does not
|
||||||
|
// point at the rule, so it is not listed here — it stays readable on the
|
||||||
|
// lesson, where the judgment was made.
|
||||||
|
const lessons = computed(() =>
|
||||||
|
(store.currentRule?.lessons ?? []).filter((l) => l.state !== "rejected"),
|
||||||
|
);
|
||||||
|
|
||||||
// The label a reader needs to judge an edge, not the stored token.
|
// The label a reader needs to judge an edge, not the stored token.
|
||||||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||||||
@@ -279,6 +287,21 @@ watch(() => props.ruleId, load);
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="lessons.length" class="relations">
|
||||||
|
<h3>Lessons that are instances of it</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="l in lessons" :key="l.id" class="relation">
|
||||||
|
<router-link :to="`/lessons/${l.id}`" class="relation-target">{{ l.title }}</router-link>
|
||||||
|
<span v-if="l.state === 'suggested'" class="rule-chip">suggested</span>
|
||||||
|
<span v-if="l.note" class="relation-note">{{ l.note }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="field-note">
|
||||||
|
The situations that keep proving this rule. A confirmed lesson brings the rule along
|
||||||
|
when it surfaces; a suggested one waits for a judgment on the lesson's page.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Why
|
Why
|
||||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||||
@@ -394,3 +417,8 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
|||||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
|
||||||
|
panes already use, loaded here because the slide-over can open with no
|
||||||
|
pane that loads it (a link straight to ?rule=N). -->
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Whether the reader may change a record, from the `permission` its read
|
||||||
|
* carried. The levels are services/access.py's PERMISSION_RANK — viewer,
|
||||||
|
* editor, admin, owner — and absent means the reader owns it: the server
|
||||||
|
* labels a record only when it reached the reader through a share. This only
|
||||||
|
* decides which controls a page shows; the server enforces the write. Kept in
|
||||||
|
* one place so every page agrees, with each other and with the server, about
|
||||||
|
* who can edit. */
|
||||||
|
export const WRITE_LEVELS: readonly string[] = ["editor", "admin", "owner"];
|
||||||
|
|
||||||
|
export function canWriteRecord(permission: string | undefined): boolean {
|
||||||
|
return permission === undefined || WRITE_LEVELS.includes(permission);
|
||||||
|
}
|
||||||
@@ -16,18 +16,31 @@
|
|||||||
* difference between this kind and a rule, and it is the reason the kind
|
* difference between this kind and a rule, and it is the reason the kind
|
||||||
* exists (#3727) — sessions were proposing rules for things that should never
|
* exists (#3727) — sessions were proposing rules for things that should never
|
||||||
* have bound anyone.
|
* have bound anyone.
|
||||||
|
*
|
||||||
|
* BUT IT CAN POINT AT ONE (milestone 440). A lesson that is an instance of a
|
||||||
|
* rule says so, and the page shows which of the three answers it holds: the
|
||||||
|
* rule(s) it was judged an instance of, "no rule fits" with the reason, or
|
||||||
|
* not yet judged. The third is the one worth acting on, so it is stated as
|
||||||
|
* plainly as the other two rather than left as an empty panel.
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { deleteLesson, getLesson, type Lesson } from "@/api/lessons";
|
import {
|
||||||
|
deleteLesson,
|
||||||
|
getLesson,
|
||||||
|
judgeLessonLink,
|
||||||
|
type Lesson,
|
||||||
|
type LessonRuleLink,
|
||||||
|
} from "@/api/lessons";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
import TagPill from "@/components/TagPill.vue";
|
import TagPill from "@/components/TagPill.vue";
|
||||||
import UsageBadge from "@/components/UsageBadge.vue";
|
import UsageBadge from "@/components/UsageBadge.vue";
|
||||||
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
|
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
import { canWriteRecord } from "@/utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -55,6 +68,53 @@ function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string })
|
|||||||
return `/notes/${rec.id}`;
|
return `/notes/${rec.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
|
||||||
|
|
||||||
|
/** The links by state — each state reads differently. Confirmed links are
|
||||||
|
* the answer; suggested ones are evidence waiting on a judgment; rejected
|
||||||
|
* ones are kept so the pair is never proposed again, and shown so the reason
|
||||||
|
* stays readable. */
|
||||||
|
const linksIn = (state: LessonRuleLink["state"]) =>
|
||||||
|
computed(() => (lesson.value?.rules ?? []).filter((r) => r.state === state));
|
||||||
|
const confirmedRules = linksIn("confirmed");
|
||||||
|
const suggestedRules = linksIn("suggested");
|
||||||
|
const rejectedRules = linksIn("rejected");
|
||||||
|
|
||||||
|
/** A rule opens in the rules view's editor, which takes the rule alone. */
|
||||||
|
function ruleHref(ruleId: number) {
|
||||||
|
return { path: "/rules", query: { rule: String(ruleId) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a suggestion rests on, in the units it was proposed by: distinct
|
||||||
|
* situations, not raw arrivals — one situation repeated proves nothing. */
|
||||||
|
function evidenceLine(ev: NonNullable<LessonRuleLink["evidence"]>): string {
|
||||||
|
const situations = `${ev.situations} distinct situation${ev.situations === 1 ? "" : "s"}`;
|
||||||
|
const projects = ev.projects > 1 ? `, across ${ev.projects} projects` : "";
|
||||||
|
return `Arrived together in ${situations}${projects}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const judging = ref<number | null>(null);
|
||||||
|
|
||||||
|
/** Judge a suggested link from here. Re-reads the lesson rather than editing
|
||||||
|
* the list in place: confirming also clears a "no rule fits" answer, and the
|
||||||
|
* judgment line has to come back from the server to be right. */
|
||||||
|
async function judge(rule: LessonRuleLink, verdict: "confirm" | "reject") {
|
||||||
|
judging.value = rule.id;
|
||||||
|
try {
|
||||||
|
await judgeLessonLink(lessonId.value, rule.id, verdict);
|
||||||
|
lesson.value = await getLesson(lessonId.value);
|
||||||
|
toast.show(
|
||||||
|
verdict === "confirm"
|
||||||
|
? "Linked — the rule can now surface through this lesson"
|
||||||
|
: "Not an instance — this pair will not be suggested again",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(apiErrorMessage(e, "Failed to record the judgment"), "error");
|
||||||
|
} finally {
|
||||||
|
judging.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -141,6 +201,80 @@ onMounted(load);
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Which rule this is an instance of. Absent when the links could not
|
||||||
|
be read: that is "not attached", never "no rule". -->
|
||||||
|
<section v-if="lesson.rule_judgment" class="ld-panel">
|
||||||
|
<h2 class="ld-panel-label">Instance of</h2>
|
||||||
|
|
||||||
|
<template v-if="lesson.rule_judgment === 'linked'">
|
||||||
|
<ul v-if="confirmedRules.length" class="ld-sources">
|
||||||
|
<li v-for="r in confirmedRules" :key="r.id">
|
||||||
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
||||||
|
{{ r.title }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="r.kind === 'preference'" class="rule-chip rule-chip-preference">
|
||||||
|
preference
|
||||||
|
</span>
|
||||||
|
<p v-if="r.note" class="ld-link-note">{{ r.note }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<!-- Judged by its owner against a rule this reader cannot open. -->
|
||||||
|
<p v-else class="ld-muted">Linked to a rule that is not shared with you.</p>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="lesson.rule_judgment === 'no_rule'" class="ld-judgment">
|
||||||
|
No rule — {{ lesson.no_rule?.why }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="ld-muted ld-empty">
|
||||||
|
Not yet judged — no rule has been named for this situation, and
|
||||||
|
nobody has said none fits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="suggestedRules.length">
|
||||||
|
<h3 class="ld-sub-label">Suggested</h3>
|
||||||
|
<ul class="ld-sources">
|
||||||
|
<li v-for="r in suggestedRules" :key="r.id">
|
||||||
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
||||||
|
{{ r.title }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="r.kind === 'preference'" class="rule-chip rule-chip-preference">
|
||||||
|
preference
|
||||||
|
</span>
|
||||||
|
<p v-if="r.evidence" class="ld-link-note">{{ evidenceLine(r.evidence) }}</p>
|
||||||
|
<div v-if="canWrite" class="ld-judge">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-ghost ld-small"
|
||||||
|
:disabled="judging === r.id"
|
||||||
|
@click="judge(r, 'confirm')"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ld-ghost ld-small"
|
||||||
|
:disabled="judging === r.id"
|
||||||
|
@click="judge(r, 'reject')"
|
||||||
|
>
|
||||||
|
Not an instance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="rejectedRules.length">
|
||||||
|
<h3 class="ld-sub-label">Judged not an instance</h3>
|
||||||
|
<ul class="ld-sources ld-rejected">
|
||||||
|
<li v-for="r in rejectedRules" :key="r.id">
|
||||||
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
||||||
|
{{ r.title }}
|
||||||
|
</router-link>
|
||||||
|
<p v-if="r.note" class="ld-link-note">{{ r.note }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="lesson.tags?.length" class="ld-tags">
|
<section v-if="lesson.tags?.length" class="ld-tags">
|
||||||
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
||||||
</section>
|
</section>
|
||||||
@@ -263,6 +397,24 @@ onMounted(load);
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ld-link-note {
|
||||||
|
margin: var(--fs-space-1) 0 0;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: var(--fs-size-label);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.ld-judgment { margin: 0; font-size: 0.9rem; line-height: 1.5; }
|
||||||
|
.ld-sub-label {
|
||||||
|
margin: var(--fs-space-3) 0 var(--fs-space-1);
|
||||||
|
font-size: var(--fs-size-label);
|
||||||
|
font-weight: var(--fs-weight-medium);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.ld-rejected .ld-link { color: var(--fs-text-secondary); }
|
||||||
|
.ld-judge { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-1); }
|
||||||
|
.ld-small { padding: 0.15rem 0.6rem; font-size: var(--fs-size-label); }
|
||||||
|
.ld-ghost:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||||
|
|
||||||
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
|
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
|
||||||
|
|
||||||
.ld-origin {
|
.ld-origin {
|
||||||
@@ -307,3 +459,7 @@ onMounted(load);
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
|
||||||
|
everywhere else, reused rather than re-spelled. -->
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
import { canWriteRecord } from "@/utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,10 +23,7 @@ const showDeleteConfirm = ref(false);
|
|||||||
|
|
||||||
const id = computed(() => Number(route.params.id));
|
const id = computed(() => Number(route.params.id));
|
||||||
|
|
||||||
const canWrite = computed(() => {
|
const canWrite = computed(() => canWriteRecord(snippet.value?.permission));
|
||||||
const p = snippet.value?.permission;
|
|
||||||
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
|
||||||
});
|
|
||||||
|
|
||||||
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Both records show the lesson → rule link in the web UI (milestone 440, #4635).
|
||||||
|
|
||||||
|
The frontend has no component test runner, so these read the source — and
|
||||||
|
each is written against something the backend owns, so a drift on either
|
||||||
|
side fails here rather than rendering a page that quietly reads wrong:
|
||||||
|
|
||||||
|
- the client's judgment and link-state unions are the service's and the
|
||||||
|
model's own values, so a renamed state cannot leave a branch that never
|
||||||
|
matches;
|
||||||
|
- the lesson page branches on every judgment, with the unjudged answer as
|
||||||
|
the fall-through, so "not yet judged" is stated rather than left blank;
|
||||||
|
- the write-level list the pages hide controls by is the server's
|
||||||
|
PERMISSION_RANK from editor up, which is the drift that had the snippet
|
||||||
|
page hiding its edit controls from shared editors (`"edit"` is not a level
|
||||||
|
the server sends).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scribe.models.lesson_rule_link import LINK_STATES
|
||||||
|
from scribe.services import lesson_rules as links_svc
|
||||||
|
from scribe.services.access import PERMISSION_RANK
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(rel: str) -> str:
|
||||||
|
return (FRONTEND / rel).read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def _union(source: str, name: str) -> set[str]:
|
||||||
|
"""The string members of `export type <name> = "a" | "b" …;`."""
|
||||||
|
m = re.search(rf"export type {name}\s*=\s*([^;]+);", source)
|
||||||
|
assert m, f"type {name} not found"
|
||||||
|
return set(re.findall(r'"([^"]+)"', m.group(1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _template(view: str) -> str:
|
||||||
|
return view[view.index("<template>"):view.rindex("</template>")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_client_judgment_union_is_the_services_three_answers():
|
||||||
|
assert _union(_read("api/lessons.ts"), "RuleJudgment") == {
|
||||||
|
links_svc.LINKED, links_svc.NO_RULE, links_svc.UNJUDGED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_client_link_states_are_the_models():
|
||||||
|
assert _union(_read("api/lessons.ts"), "LinkState") == set(LINK_STATES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lesson_page_answers_all_three_judgments():
|
||||||
|
"""`linked` and `no_rule` are tested by name; `unjudged` is the v-else
|
||||||
|
that closes the same chain, so a fourth state the server might add reads
|
||||||
|
as unjudged rather than as nothing."""
|
||||||
|
tpl = _template(_read("views/LessonDetailView.vue"))
|
||||||
|
linked = tpl.index(f"lesson.rule_judgment === '{links_svc.LINKED}'")
|
||||||
|
no_rule = tpl.index(f"lesson.rule_judgment === '{links_svc.NO_RULE}'")
|
||||||
|
assert linked < no_rule
|
||||||
|
after = tpl[no_rule:]
|
||||||
|
# The no-rule answer renders its reason, and the chain closes on a v-else.
|
||||||
|
para_end = after.index("</p>")
|
||||||
|
assert "lesson.no_rule?.why" in after[:para_end]
|
||||||
|
closing = after[para_end + len("</p>"):]
|
||||||
|
assert re.match(r"\s*<p v-else\b", closing), "the unjudged branch is not the chain's v-else"
|
||||||
|
assert "Not yet judged" in closing[:closing.index("</p>")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_is_gated_on_the_judgment_being_attached():
|
||||||
|
"""An absent `rule_judgment` is "the links could not be read" — never
|
||||||
|
"no rule" — so the panel must not render a state for it."""
|
||||||
|
tpl = _template(_read("views/LessonDetailView.vue"))
|
||||||
|
assert '<section v-if="lesson.rule_judgment"' in tpl
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_pages_link_through_to_the_other_record():
|
||||||
|
lesson_tpl = _read("views/LessonDetailView.vue")
|
||||||
|
assert re.search(r'path:\s*"/rules",\s*query:\s*\{\s*rule:', lesson_tpl)
|
||||||
|
rule_tpl = _template(_read("components/rules/RuleEditorSlideOver.vue"))
|
||||||
|
assert ':to="`/lessons/${l.id}`"' in rule_tpl
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_kind_and_state_markers_reuse_rule_chip():
|
||||||
|
"""No new chip style where `.rule-chip` serves: both components load the
|
||||||
|
shared sheet and neither re-declares the class in its own block."""
|
||||||
|
for rel in ("views/LessonDetailView.vue", "components/rules/RuleEditorSlideOver.vue"):
|
||||||
|
src = _read(rel)
|
||||||
|
assert 'class="rule-chip' in _template(src), rel
|
||||||
|
assert '<style src="@/assets/rules-shared.css" />' in src, rel
|
||||||
|
scoped = src[src.index("<style scoped>"):]
|
||||||
|
scoped = scoped[:scoped.index("</style>")]
|
||||||
|
assert ".rule-chip" not in scoped, f"{rel} re-spells the chip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_write_levels_are_the_servers():
|
||||||
|
src = _read("utils/permission.ts")
|
||||||
|
m = re.search(r"WRITE_LEVELS[^=]*=\s*\[([^\]]*)\]", src)
|
||||||
|
assert m, "WRITE_LEVELS not found"
|
||||||
|
client = set(re.findall(r'"([^"]+)"', m.group(1)))
|
||||||
|
server = {p for p, rank in PERMISSION_RANK.items() if rank >= PERMISSION_RANK["editor"]}
|
||||||
|
assert client == server
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_page_spells_its_own_write_check():
|
||||||
|
"""The helper exists so pages agree; a page comparing against a share
|
||||||
|
level by hand is the copy that drifted to `"edit"`. Matched on the names
|
||||||
|
only a share level uses — `admin` and `owner` are also user roles, and
|
||||||
|
`role === "admin"` is not this."""
|
||||||
|
offenders = [
|
||||||
|
str(p.relative_to(FRONTEND))
|
||||||
|
for p in [*FRONTEND.rglob("*.vue"), *FRONTEND.rglob("*.ts")]
|
||||||
|
if p != FRONTEND / "utils" / "permission.ts"
|
||||||
|
and re.search(r'[!=]==\s*"(?:viewer|editor|edit)"', p.read_text())
|
||||||
|
]
|
||||||
|
assert offenders == []
|
||||||
Reference in New Issue
Block a user