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:
@@ -16,18 +16,31 @@
|
||||
* 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
|
||||
* 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 { useRoute, useRouter } from "vue-router";
|
||||
|
||||
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 TagPill from "@/components/TagPill.vue";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { canWriteRecord } from "@/utils/permission";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -55,6 +68,53 @@ function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string })
|
||||
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() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -141,6 +201,80 @@ onMounted(load);
|
||||
</ul>
|
||||
</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">
|
||||
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
||||
</section>
|
||||
@@ -263,6 +397,24 @@ onMounted(load);
|
||||
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-origin {
|
||||
@@ -307,3 +459,7 @@ onMounted(load);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</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 ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { canWriteRecord } from "@/utils/permission";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -22,10 +23,7 @@ const showDeleteConfirm = ref(false);
|
||||
|
||||
const id = computed(() => Number(route.params.id));
|
||||
|
||||
const canWrite = computed(() => {
|
||||
const p = snippet.value?.permission;
|
||||
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
||||
});
|
||||
const canWrite = computed(() => canWriteRecord(snippet.value?.permission));
|
||||
|
||||
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user