diff --git a/CLAUDE.md b/CLAUDE.md index b90d0a2..b446c51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **Input is validated in the model, not just in the form.** `error.rs` carries `ValidationError`; `SpaceDraft::validated_name` and `spaces::model::validate_move_target` run before locking. A rule held only by a form is not held. Language needs no validation any more — an unknown value fails deserialisation at the bridge, and cannot be written on the front at all. - **Data-source seam.** Components and stores never touch a data source directly: everything goes through `NotesRepository` / `SpacesRepository`, plain `providedIn: 'root'` classes — no interface, no `InjectionToken`, nothing bound in `app.config.ts`, because there is exactly one implementation. Specs substitute them by class (`{ provide: NotesRepository, useValue: fake }`, via `provideAppTesting()`); the fakes in `src/testing/` keep their compile-time check with `implements Pick`. Don't call a generated command from a component or a store — the repositories are the only callers. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. - **A conversion exists only where the wire shape differs from the model shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: the generated wire types (imported under a `Wire*` name, never re-declared), the repository, the conversion. Notes need theirs (`Date` ↔ ISO, patch copied field by field) and it lives in `core/data/note.mapper.ts`. Spaces don't: `Space` crosses the bridge as itself. Don't reintroduce an identity mapper for symmetry. The section key no longer needs a runtime guard either — `NoteSectionKey` is generated, so a variant added in Rust is a compile error. +- **A space can be pinned, and that is the only order there is to choose.** `spaces.pinned` (migration 9) hoists it to the head of `list_spaces`, which then falls back on `name COLLATE NOCASE` — the same shape the canvas gives notes. A `position` column was the alternative and was refused: an order the user maintains is a second thing to keep consistent on every insert and delete, where pinning is a boolean and a gesture the application already has. ⚠️ `Space.pinned` carries `#[serde(default)]` so an export file written before the column stays readable, and specta turns that into an **optional** key — `SpacesRepository` is where it becomes the boolean the model requires. - **`null` space means "all spaces".** `SpacesStore.activeSpaceId()` is `null` when the user wants every space, and that is a choice, not a loading state — don't add an "All" row to the spaces data, notes would end up filed into it. A note always has a `spaceId`; creating one with no space available is refused on purpose. - **Deleting a space needs a refuge.** `notes.space_id` carries `ON DELETE CASCADE`, so `delete_space(id, targetSpaceId)` moves the notes _then_ deletes, in one transaction — there is no one-argument variant, which would have made data loss the default. It leaves `updated_at` alone (the canvas sorts on it, and touching it would float the whole absorbed space to the top). A space can't be its own refuge: `spaces::model::validate_move_target` refuses it before any SQL runs. `targetSpaceId` is the first multi-word command argument, so it's the one that actually exercises Tauri's camelCase renaming. - **"À trier" = a note with a deadline.** The `untriaged` filter, the `⏳` badge and the "à trier bientôt" section hint all read the same field, `lifecycle`. It's set from the editor's date field, converted to the **end of the local day** (`endOfLocalDay`) — midnight would make a note dated today expired on the spot — and read back in local time too. Remove that field and all three affordances go permanently empty, which is exactly the state they were in before it existed. diff --git a/docs/architecture.md b/docs/architecture.md index bb3eb09..7582b51 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1514,7 +1514,7 @@ The commands, grouped by the feature that owns them: | Feature | Commands | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `notes` | `query_notes`, `create_note`, `update_note`, `delete_note`, `delete_notes`, `restore_notes`, `list_trash`, `purge_notes`, `empty_trash`, `move_notes`, `tag_notes`, `list_tags`, `rename_tag`, `merge_tags`, `delete_tag`, `fill_placeholders`, `set_placeholder_values` | -| `spaces` | `list_spaces`, `create_space`, `rename_space`, `delete_space` | +| `spaces` | `list_spaces`, `create_space`, `rename_space`, `pin_space`, `delete_space` | | `attachments` | `attach_file`, `attach_clipboard_image`, `list_attachments`, `read_attachment`, `open_attachment`, `save_attachment`, `delete_attachment` | | `transfer` | `export_notes`, `export_selection`, `import_notes`, `share_notes` | | `desktop` | `sync_tray`, `unavailable_shortcuts` | @@ -2170,8 +2170,8 @@ The numeric prefix on each file is therefore load-bearing: it is the run order. that resolves the seeded space — `homeSpaceId()` records it while exactly one exists and writes it to a marker file, because WebdriverIO gives each spec file its own worker process and a module-level cache would be empty again in the next one. ⚠️ It cannot be `listSpaces()[0]`: -`list_spaces` orders by `name COLLATE NOCASE`, so after another file creates `Ops` the first -row is no longer the seeded space. +`list_spaces` orders pinned first and then by `name COLLATE NOCASE`, so after another file +creates `Ops` — or pins anything — the first row is no longer the seeded space. **⚠️ There is no restart, and no spec may claim one.** `reopenSession()` is a `browser.reloadSession()`: it tears the session down and opens a new one against the same diff --git a/e2e/pageobjects/overlays.page.ts b/e2e/pageobjects/overlays.page.ts index e4797ce..099074a 100644 --- a/e2e/pageobjects/overlays.page.ts +++ b/e2e/pageobjects/overlays.page.ts @@ -48,6 +48,18 @@ export const spaces = { * off a template ref — but the day that control becomes signal-bound, the driver's * missing `change` would silently delete with the wrong refuge. */ + /** From the same panel as the rename and the delete, which the ⋯ opens. */ + /** + * ⚠️ Closes behind itself. The edit panel **replaces** the menu rather than sitting + * over it, so the dropdown is still there afterwards and `open()` — which checks for + * exactly that — would do nothing, leaving the next caller in the panel. + */ + async togglePin(id: string): Promise { + await $(`${testid('space-edit')}[data-space-id="${id}"]`).click(); + await $(testid('space-pin')).click(); + await spaces.close(); + }, + async remove(id: string, refugeId: string): Promise { await $(`${testid('space-edit')}[data-space-id="${id}"]`).click(); await setNativeValue(testid('space-move-target'), refugeId); diff --git a/e2e/specs/05-spaces.e2e.ts b/e2e/specs/05-spaces.e2e.ts index 74b2063..3d2929d 100644 --- a/e2e/specs/05-spaces.e2e.ts +++ b/e2e/specs/05-spaces.e2e.ts @@ -74,6 +74,68 @@ describe('Spaces', () => { expect(after?.name).toBe('Lectures'); }); + /** + * Name order was the only order there had ever been, so the space opened + * every morning sat wherever its initial fell. Pinning hoists it, the way it does a + * note on the canvas — and the order is SQL's, which only a real database can prove. + * + * ⚠️ Its own space, named to sort **last**: Mocha runs a nested suite after its + * siblings, and by then the tests above have renamed and deleted theirs. + */ + describe('pinning one to the head of the list', () => { + const name = 'Zzz pinned'; + let id = ''; + + before(async () => { + id = (await bridge.createSpace({ name })).id; + // Written straight through the bridge, so the front end has never heard of it. + await reloadCanvas(); + }); + + after(async () => { + const refuge = (await bridge.listSpaces()).find((space) => space.id !== id); + await bridge.deleteSpace(id, refuge!.id); + }); + + it('hoists it whatever its name, and lets it fall back', async () => { + expect((await bridge.listSpaces()).at(-1)?.id).toBe(id); + + await spaces.open(); + await spaces.togglePin(id); + await browser.pause(500); + + const pinned = await bridge.listSpaces(); + expect(pinned[0]?.id).toBe(id); + expect(pinned[0]?.pinned).toBe(true); + + await spaces.open(); + await spaces.togglePin(id); + await browser.pause(500); + + const loose = await bridge.listSpaces(); + expect(loose.at(-1)?.id).toBe(id); + expect(loose.at(-1)?.pinned).toBe(false); + }); + + /** + * ⚠️ A rename answers with the row it read back rather than with what it was sent, + * which is what keeps it from quietly reporting a pinned space as unpinned. + */ + it('survives a rename', async () => { + await spaces.open(); + await spaces.togglePin(id); + await browser.pause(500); + + await spaces.open(); + await spaces.rename(id, 'Zzz renamed'); + await browser.pause(500); + + const after = (await bridge.listSpaces()).find((space) => space.id === id); + expect(after?.name).toBe('Zzz renamed'); + expect(after?.pinned).toBe(true); + }); + }); + it('filters the canvas down to the active space', async () => { const target = (await bridge.listSpaces()).find((space) => space.name === 'Lectures')!; await bridge.createNote(draft({ spaceId: target.id, title: 'Only in Lectures' })); diff --git a/src-tauri/migrations/2026-09-15-000009_pinned_spaces/down.sql b/src-tauri/migrations/2026-09-15-000009_pinned_spaces/down.sql new file mode 100644 index 0000000..70255a0 --- /dev/null +++ b/src-tauri/migrations/2026-09-15-000009_pinned_spaces/down.sql @@ -0,0 +1 @@ +ALTER TABLE spaces DROP COLUMN pinned; diff --git a/src-tauri/migrations/2026-09-15-000009_pinned_spaces/up.sql b/src-tauri/migrations/2026-09-15-000009_pinned_spaces/up.sql new file mode 100644 index 0000000..3900ca2 --- /dev/null +++ b/src-tauri/migrations/2026-09-15-000009_pinned_spaces/up.sql @@ -0,0 +1,7 @@ +-- Spaces came back in one order and only one: `name COLLATE NOCASE`. The space opened +-- every morning sat wherever its initial put it, under archives nobody touches. +-- +-- A boolean and not a `position` column: an order the user maintains is a second thing +-- to keep consistent on every insert and delete, and pinning is the gesture the app +-- already has for "keep this within reach". +ALTER TABLE spaces ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 3659be3..d7bce91 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -8,6 +8,7 @@ diesel::table! { spaces (id) { id -> Text, name -> Text, + pinned -> Bool, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c836f52..a5d876c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,7 +34,7 @@ use notes::{ query_notes, rename_tag, restore_notes, set_global_placeholders, set_placeholder_values, tag_notes, update_note, }; -use spaces::{create_space, delete_space, list_spaces, rename_space}; +use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space}; use transfer::{export_notes, export_selection, import_notes, share_notes}; /// Resolved from the manifest and not from the current directory: a relative path @@ -78,6 +78,7 @@ fn ipc_builder() -> Builder { list_spaces, create_space, rename_space, + pin_space, delete_space, attach_file, attach_clipboard_image, diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index f55ac9f..4bbd1cb 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -38,6 +38,15 @@ pub fn rename_space(id: String, draft: SpaceDraft, db: State<'_, Db>) -> Result< Ok(store::rename(&mut connection, &id, &name)?) } +/// Hoists a space to the head of the list, or lets it fall back among the others. +#[tauri::command(async)] +#[specta::specta] +pub fn pin_space(id: String, pinned: bool, db: State<'_, Db>) -> Result { + let mut connection = lock(&db)?; + + Ok(store::set_pinned(&mut connection, &id, pinned)?) +} + #[tauri::command(async)] #[specta::specta] pub fn delete_space( diff --git a/src-tauri/src/spaces/model.rs b/src-tauri/src/spaces/model.rs index e085b59..93a5f1a 100644 --- a/src-tauri/src/spaces/model.rs +++ b/src-tauri/src/spaces/model.rs @@ -12,6 +12,9 @@ pub struct Space { pub id: String, /// Uniqueness is case-insensitive, decided by persistence. pub name: String, + /// Hoisted to the head of the list, the way a pinned note is on the canvas. + #[serde(default)] + pub pinned: bool, } #[derive(Debug, Clone, Deserialize, Type)] diff --git a/src-tauri/src/spaces/store.rs b/src-tauri/src/spaces/store.rs index 34cd379..7419320 100644 --- a/src-tauri/src/spaces/store.rs +++ b/src-tauri/src/spaces/store.rs @@ -10,18 +10,51 @@ use uuid::Uuid; /// An empty list is valid: it is the state of the first launch. pub fn list(connection: &mut SqliteConnection) -> Result, StorageError> { let rows = spaces::table - .select((spaces::id, spaces::name)) + .select((spaces::id, spaces::name, spaces::pinned)) + // Pinned first, then by name — the same shape the canvas gives notes. + .order(spaces::pinned.desc()) // Raw fragment: Diesel does not model collations, and sorting as BINARY // would place "personal" after "Zebra". - .order(sql::("name COLLATE NOCASE")) - .load::<(String, String)>(connection)?; + .then_order_by(sql::("name COLLATE NOCASE")) + .load::<(String, String, bool)>(connection)?; Ok(rows .into_iter() - .map(|(id, name)| Space { id, name }) + .map(|(id, name, pinned)| Space { id, name, pinned }) .collect()) } +/// Reads one back, so a write answers with the row rather than with what it sent — +/// a rename must not quietly drop whether the space was pinned. +fn find(connection: &mut SqliteConnection, id: &str) -> Result { + spaces::table + .find(id) + .select((spaces::id, spaces::name, spaces::pinned)) + .first::<(String, String, bool)>(connection) + .optional()? + .map(|(id, name, pinned)| Space { id, name, pinned }) + .ok_or_else(|| StorageError::SpaceNotFound(id.to_string())) +} + +/// Hoists a space to the head of the list, or lets it fall back among the others. +pub fn set_pinned( + connection: &mut SqliteConnection, + id: &str, + pinned: bool, +) -> Result { + connection.transaction(|connection| { + if !exists(connection, id)? { + return Err(StorageError::SpaceNotFound(id.to_string())); + } + + diesel::update(spaces::table.find(id)) + .set(spaces::pinned.eq(pinned)) + .execute(connection)?; + + find(connection, id) + }) +} + /// The foreign key would catch it too, but with an unreadable SQLite message /// whereas the front end displays the error. pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result { @@ -74,6 +107,7 @@ pub fn create(connection: &mut SqliteConnection, name: &str) -> Result { - return unwrap('list_spaces', await commands.listSpaces()); + return unwrap('list_spaces', await commands.listSpaces()).map(toSpace); } async create(draft: SpaceDraft): Promise { - return unwrap('create_space', await commands.createSpace(draft)); + return toSpace(unwrap('create_space', await commands.createSpace(draft))); } async rename(id: string, draft: SpaceDraft): Promise { - return unwrap('rename_space', await commands.renameSpace(id, draft)); + return toSpace(unwrap('rename_space', await commands.renameSpace(id, draft))); + } + + /** Hoists it to the head of the list, or lets it fall back among the others. */ + async setPinned(id: string, pinned: boolean): Promise { + return toSpace(unwrap('pin_space', await commands.pinSpace(id, pinned))); } /** `targetSpaceId` receives the deleted space's notes. */ diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index ac2e304..85bd290 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -56,6 +56,8 @@ export const commands = { listSpaces: () => typedError(__TAURI_INVOKE("list_spaces")), createSpace: (draft: SpaceDraft) => typedError(__TAURI_INVOKE("create_space", { draft })), renameSpace: (id: string, draft: SpaceDraft) => typedError(__TAURI_INVOKE("rename_space", { id, draft })), + /** Hoists a space to the head of the list, or lets it fall back among the others. */ + pinSpace: (id: string, pinned: boolean) => typedError(__TAURI_INVOKE("pin_space", { id, pinned })), deleteSpace: (id: string, targetSpaceId: string) => typedError(__TAURI_INVOKE("delete_space", { id, targetSpaceId })), attachFile: (noteId: string, path: string) => typedError(__TAURI_INVOKE("attach_file", { noteId, path })), /** @@ -410,6 +412,8 @@ export type Space = { id: string, /** Uniqueness is case-insensitive, decided by persistence. */ name: string, + /** Hoisted to the head of the list, the way a pinned note is on the canvas. */ + pinned?: boolean, }; export type SpaceDraft = { diff --git a/src/app/core/model/space.model.ts b/src/app/core/model/space.model.ts index 683c3cb..3d7a6d9 100644 --- a/src/app/core/model/space.model.ts +++ b/src/app/core/model/space.model.ts @@ -1,7 +1,9 @@ export interface Space { readonly id: string; readonly name: string; + /** Hoisted to the head of the list, the way a pinned note is on the canvas. */ + readonly pinned: boolean; } /** The `id` is assigned by persistence and never by the front end. */ -export type SpaceDraft = Omit; +export type SpaceDraft = Omit; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 880f890..9830132 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -10,6 +10,9 @@ "newSpacePlaceholder": "Space name", "newSpaceSubmit": "Create", "spaceOptionsLabel": "Options for space {{name}}", + "pinSpace": "Pin this space", + "unpinSpace": "Unpin", + "pinnedSpace": "Pinned space", "renameSpaceLabel": "New space name", "renameSpaceSubmit": "Rename", "moveNotesTo": "Move notes to", @@ -139,6 +142,7 @@ "spacesLoadFailed": "Could not load spaces.", "spaceCreateFailed": "Could not create the space.", "spaceRenameFailed": "Could not rename the space.", + "spacePinFailed": "Could not pin this space.", "spaceDeleteFailed": "Could not delete the space. Its notes have not moved.", "spaceNameTaken": "A space named “{{name}}” already exists.", "spaceRequired": "Create a space first to store your notes in.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 5b20926..416686a 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -10,6 +10,9 @@ "newSpacePlaceholder": "Nom de l'espace", "newSpaceSubmit": "Créer", "spaceOptionsLabel": "Options de l'espace {{name}}", + "pinSpace": "Épingler cet espace", + "unpinSpace": "Ne plus épingler", + "pinnedSpace": "Espace épinglé", "renameSpaceLabel": "Nouveau nom de l'espace", "renameSpaceSubmit": "Renommer", "moveNotesTo": "Déplacer les notes vers", @@ -139,6 +142,7 @@ "spacesLoadFailed": "Impossible de charger les espaces.", "spaceCreateFailed": "Impossible de créer l'espace.", "spaceRenameFailed": "Impossible de renommer l'espace.", + "spacePinFailed": "Impossible d’épingler cet espace.", "spaceDeleteFailed": "Impossible de supprimer l'espace. Ses notes n'ont pas bougé.", "spaceNameTaken": "Un espace nommé « {{name}} » existe déjà.", "spaceRequired": "Créez d'abord un espace pour y ranger vos notes.", diff --git a/src/app/core/state/sample-notes.service.spec.ts b/src/app/core/state/sample-notes.service.spec.ts index cec8e7c..6910f89 100644 --- a/src/app/core/state/sample-notes.service.spec.ts +++ b/src/app/core/state/sample-notes.service.spec.ts @@ -4,6 +4,7 @@ import { PreferencesService } from '@core/services/preferences/preferences.servi import { NotesRepository } from '@core/data/notes.repository'; import { SpacesRepository } from '@core/data/spaces.repository'; import { NoteDraft } from '@core/model/note.model'; +import { Space } from '@core/model/space.model'; import { FakeNotesRepository } from '@testing/fake-notes-repository'; import { FakeSpacesRepository } from '@testing/fake-spaces-repository'; import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; @@ -19,7 +20,7 @@ describe('SampleNotesService', () => { /** The drafts handed to the repository, in the order they were written. */ const drafts = (): NoteDraft[] => created.mock.calls.map(([draft]) => draft); - function setUp(existingSpaces: { id: string; name: string }[] = []): void { + function setUp(existingSpaces: Space[] = []): void { TestBed.resetTestingModule(); notes = new FakeNotesRepository(); spaces = new FakeSpacesRepository(existingSpaces); @@ -94,7 +95,7 @@ describe('SampleNotesService', () => { }); it('leaves an existing installation alone, and stops looking', async () => { - setUp([{ id: 'space-1', name: 'Perso' }]); + setUp([{ id: 'space-1', name: 'Perso', pinned: false }]); expect(await service.seedIfFirstRun()).toBe(false); expect(drafts()).toHaveLength(0); diff --git a/src/app/core/state/spaces.store.spec.ts b/src/app/core/state/spaces.store.spec.ts index 85a0ce1..e69721f 100644 --- a/src/app/core/state/spaces.store.spec.ts +++ b/src/app/core/state/spaces.store.spec.ts @@ -8,8 +8,8 @@ import { provideAppTesting } from '@testing/testing.providers'; import { SpacesStore } from './spaces.store'; const SPACES: readonly Space[] = [ - { id: 'work', name: 'Work' }, - { id: 'personal', name: 'Personal' }, + { id: 'work', name: 'Work', pinned: false }, + { id: 'personal', name: 'Personal', pinned: false }, ]; interface Harness { @@ -225,6 +225,56 @@ describe('SpacesStore', () => { }); }); + /** + * Name order alone put the space opened every morning wherever its initial fell. + * Pinning hoists it, the way it does a note on the canvas. + */ + describe('togglePinned', () => { + it('sends the opposite of what the space carries', async () => { + const { store, repository } = await createStore(); + const setPinned = vi.spyOn(repository, 'setPinned'); + + await store.togglePinned('work'); + + expect(setPinned).toHaveBeenCalledWith('work', true); + }); + + /** + * ⚠️ Reloaded rather than patched in place. Pinning changes the **order**, and the + * order is the back end's — putting the returned space back where it was would + * leave it flagged and still buried. + */ + it('takes the new order from the backend rather than keeping its own', async () => { + const { store } = await createStore(); + expect(store.spaces().map((space) => space.id)).toEqual(['work', 'personal']); + + await store.togglePinned('personal'); + + // Waited for: the reload is a round trip, so the new order arrives after the call + // returns — the list reorders a moment later, which is what the user sees too. + await vi.waitFor(() => expect(store.spaces().map((space) => space.id)).toEqual(['personal', 'work'])); + expect(store.spaces()[0].pinned).toBe(true); + }); + + it('writes nothing for a space it does not hold', async () => { + const { store, repository } = await createStore(); + const setPinned = vi.spyOn(repository, 'setPinned'); + + expect(await store.togglePinned('missing')).toBe(false); + expect(setPinned).not.toHaveBeenCalled(); + }); + + it('leaves the list alone when the write fails, and says so', async () => { + const { store, repository } = await createStore(); + const notifier = TestBed.inject(ErrorNotifier); + repository.failNext = new Error('disk full'); + + expect(await store.togglePinned('work')).toBe(false); + expect(notifier.notice()?.ref.key).toBe('errors.spacePinFailed'); + expect(store.spaces()).toEqual(SPACES); + }); + }); + describe('deleteSpace', () => { it('drops the space and activates the one that took its notes', async () => { const { store } = await createStore(); diff --git a/src/app/core/state/spaces.store.ts b/src/app/core/state/spaces.store.ts index 0d9df76..3718878 100644 --- a/src/app/core/state/spaces.store.ts +++ b/src/app/core/state/spaces.store.ts @@ -94,6 +94,24 @@ export class SpacesStore { return true; } + /** + * ⚠️ Reloads rather than patching the one row. Pinning changes the **order** of the + * list, and the order is the back end's — putting the returned space back where it + * was would leave it flagged and still buried. + */ + async togglePinned(id: string): Promise { + const current = this.spaces().find((space) => space.id === id); + if (!current) return false; + + const updated = await this.notifier.attempt('errors.spacePinFailed', () => + this.repository.setPinned(id, !current.pinned), + ); + if (!updated) return false; + + this.spacesResource.reload(); + return true; + } + /** * `targetSpaceId` becomes active: the notes have just landed there, and falling * back to "all spaces" would lose sight of where they went. diff --git a/src/app/notes/canvas/note-section/note-card/note-card-menu/note-card-menu.component.spec.ts b/src/app/notes/canvas/note-section/note-card/note-card-menu/note-card-menu.component.spec.ts index 29386c1..659b209 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card-menu/note-card-menu.component.spec.ts +++ b/src/app/notes/canvas/note-section/note-card/note-card-menu/note-card-menu.component.spec.ts @@ -5,9 +5,9 @@ import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; import { NoteCardMenuComponent } from './note-card-menu.component'; const SPACES: readonly Space[] = [ - { id: 'work', name: 'Work' }, - { id: 'personal', name: 'Personal' }, - { id: 'archive', name: 'Archive' }, + { id: 'work', name: 'Work', pinned: false }, + { id: 'personal', name: 'Personal', pinned: false }, + { id: 'archive', name: 'Archive', pinned: false }, ]; describe('NoteCardMenuComponent', () => { diff --git a/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts b/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts index 8157c49..d620fe6 100644 --- a/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts +++ b/src/app/notes/canvas/note-section/note-card/note-card.component.spec.ts @@ -17,8 +17,8 @@ import { NoteActivation, NoteCardComponent } from './note-card.component'; const NEWLINE = String.fromCharCode(10); const SPACES: readonly Space[] = [ - { id: 'work', name: 'Work' }, - { id: 'personal', name: 'Personal' }, + { id: 'work', name: 'Work', pinned: false }, + { id: 'personal', name: 'Personal', pinned: false }, ]; describe('NoteCardComponent', () => { diff --git a/src/app/notes/canvas/note-section/note-section.component.spec.ts b/src/app/notes/canvas/note-section/note-section.component.spec.ts index f321b7b..8559fac 100644 --- a/src/app/notes/canvas/note-section/note-section.component.spec.ts +++ b/src/app/notes/canvas/note-section/note-section.component.spec.ts @@ -20,7 +20,7 @@ describe('NoteSectionComponent', () => { TestBed.configureTestingModule({ imports: [NoteSectionComponent], // A draft needs somewhere to be filed: creating with no space is refused. - providers: [provideAppTesting({ spaces: [{ id: 'space-1', name: 'Space one' }] })], + providers: [provideAppTesting({ spaces: [{ id: 'space-1', name: 'Space one', pinned: false }] })], }); fixture = TestBed.createComponent(NoteSectionComponent); fixture.componentRef.setInput('section', createSection('today')); diff --git a/src/app/notes/header/space-switcher/space-switcher.component.html b/src/app/notes/header/space-switcher/space-switcher.component.html index 46f732b..be4109d 100644 --- a/src/app/notes/header/space-switcher/space-switcher.component.html +++ b/src/app/notes/header/space-switcher/space-switcher.component.html @@ -20,6 +20,18 @@

{{ edited.name }}

+ +
+ @if (space.pinned) { + + {{ 'notes.pinnedSpace' | transloco }} + } {{ space.name }}