Difference between revisions of "Vuetify & vue-postgrest todos"

From UVOO Tech Wiki
Jump to navigation Jump to search
(Created page with "Perfect—here’s a Vue + Vuetify + vue-postgrest CRUD example for a todos table. It shows list, add, edit, and delete using v-data-table and dialogs. It uses vue-postgrest...")
 
 
Line 7: Line 7:
  
 
# Vue 3 + Vite + Vuetify
 
# Vue 3 + Vite + Vuetify
 +
```
 
npm create vite@latest vue-pg-todos -- --template vue
 
npm create vite@latest vue-pg-todos -- --template vue
 
cd vue-pg-todos
 
cd vue-pg-todos
 
npm i vuetify @mdi/font vue-postgrest
 
npm i vuetify @mdi/font vue-postgrest
 +
```
  
 
main.ts
 
main.ts
 +
```
  
 
// src/main.ts
 
// src/main.ts
Line 56: Line 59:
 
import TodosCrud from './components/TodosCrud.vue'
 
import TodosCrud from './components/TodosCrud.vue'
 
</script>
 
</script>
 
+
```
  
 
---
 
---
Line 62: Line 65:
 
CRUD component
 
CRUD component
  
 +
```
 
<!-- src/components/TodosCrud.vue -->
 
<!-- src/components/TodosCrud.vue -->
 
<template>
 
<template>
Line 296: Line 300:
 
})
 
})
 
</script>
 
</script>
 +
```
  
  
Line 302: Line 307:
 
PostgREST side (example table)
 
PostgREST side (example table)
  
 +
```
 
-- example schema
 
-- example schema
 
CREATE TABLE public.todos(
 
CREATE TABLE public.todos(
Line 311: Line 317:
  
 
GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO anon;  -- or your web role
 
GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO anon;  -- or your web role
 +
```
  
  

Latest revision as of 10:30, 10 August 2025

Perfect—here’s a Vue + Vuetify + vue-postgrest CRUD example for a todos table. It shows list, add, edit, and delete using v-data-table and dialogs. It uses vue-postgrest’s pg mixin, pg.$new() for creates, and per-row item.$patch() / item.$delete() for updates/deletes (that’s how the library is designed).


Project setup

Vue 3 + Vite + Vuetify

npm create vite@latest vue-pg-todos -- --template vue
cd vue-pg-todos
npm i vuetify @mdi/font vue-postgrest

main.ts

<br />// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

// Vuetify
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
const vuetify = createVuetify({
  icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
})

// vue-postgrest
import VuePostgrest from 'vue-postgrest'

// Create app
const app = createApp(App)

// Point to your PostgREST base URL
app.use(VuePostgrest, {
  apiRoot: 'http://localhost:3000/',   // e.g. http://localhost:3000/
  // headers: { Authorization: `Bearer ${yourJWT}` }  // if you use JWT
})

app.use(vuetify).mount('#app')

App.vue

<!-- src/App.vue -->
<template>
  <v-app>
    <v-main>
      <v-container class="py-8">
        <TodosCrud />
      </v-container>
    </v-main>
  </v-app>
</template>

<script setup lang="ts">
import TodosCrud from './components/TodosCrud.vue'
</script>

CRUD component

<!-- src/components/TodosCrud.vue -->
<template>
  <v-card>
    <v-card-title class="d-flex justify-space-between">
      <span class="text-h6">Todos</span>
      <div class="d-flex ga-2">
        <v-text-field
          v-model="search"
          density="compact"
          hide-details
          label="Search title"
          prepend-inner-icon="mdi-magnify"
          style="max-width: 280px"
          @keyup.enter="refresh"
        />
        <v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">
          Add
        </v-btn>
      </div>
    </v-card-title>

    <v-data-table
      :items="todos"
      :headers="headers"
      :search="search"
      :loading="pg.$get.isPending"
      item-key="id"
      class="elevation-1"
    >
      <template #item.done="{ item }">
        <v-checkbox-btn v-model="item.done" @change="saveInline(item)" />
      </template>

      <template #item.actions="{ item }">
        <v-btn
          size="small"
          icon="mdi-pencil"
          variant="text"
          @click="openEdit(item)"
        />
        <v-btn
          size="small"
          color="error"
          icon="mdi-delete"
          variant="text"
          @click="confirmDelete(item)"
        />
      </template>

      <template #no-data>
        <v-alert type="info" variant="tonal">No todos yet.</v-alert>
      </template>
    </v-data-table>
  </v-card>

  <!-- Create/Edit dialog -->
  <v-dialog v-model="dialog.open" max-width="520px">
    <v-card :title="dialog.mode === 'create' ? 'Add Todo' : 'Edit Todo'">
      <v-card-text>
        <v-form ref="formRef" @submit.prevent="submitDialog">
          <v-text-field
            v-model="dialog.model.title"
            label="Title"
            :rules="[v => !!v || 'Required']"
            required
          />
          <v-switch v-model="dialog.model.done" label="Done?" />
        </v-form>
        <v-alert
          v-if="errorMessage"
          type="error"
          variant="tonal"
          class="mt-4"
          :text="errorMessage"
        />
      </v-card-text>
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="closeDialog">Cancel</v-btn>
        <v-btn :loading="saving" color="primary" @click="submitDialog">
          Save
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>

  <!-- Delete confirm -->
  <v-dialog v-model="confirm.open" max-width="440px">
    <v-card title="Delete todo?">
      <v-card-text> This action cannot be undone. </v-card-text>
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="confirm.open = false">Cancel</v-btn>
        <v-btn color="error" :loading="saving" @click="doDelete">
          Delete
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue'
import { pg } from 'vue-postgrest' // mixin that wires PostgREST queries & models. 1

export default defineComponent({
  name: 'TodosCrud',
  mixins: [pg],
  data() {
    return {
      // vue-postgrest mixin config: point to the "todos" route
      pgConfig: {
        route: 'todos',
        query: {
          select: ['id', 'title', 'done', 'created_at'],
          order: { id: 'asc' },      // server-side ordering. 2
        },
        count: 'exact',               // Prefer: count=exact, enables pg.$range.totalCount. 3
      },

      search: '',
      headers: [
        { title: 'ID', key: 'id', sortable: true, width: 80 },
        { title: 'Title', key: 'title', sortable: true },
        { title: 'Done', key: 'done', sortable: true, width: 110 },
        { title: 'Created', key: 'created_at', sortable: true, width: 200 },
        { title: 'Actions', key: 'actions', sortable: false, width: 120 },
      ],

      dialog: {
        open: false as boolean,
        mode: 'create' as 'create' | 'edit',
        model: null as any,   // will hold a vue-postgrest GenericModel. 4
      },

      confirm: {
        open: false as boolean,
        item: null as any,
      },

      saving: false,
      errorMessage: '',
    }
  },
  computed: {
    // this.pg is a GenericCollection of GenericModels that wrap items. 5
    todos(): any[] {
      return (this as any).pg
    },
  },
  methods: {
    refresh() {
      (this as any).pg.$get()  // re-fetch list. exposes pending/error flags. 6
    },

    openCreate() {
      // Create a new client-side model with default values. 7
      this.dialog.model = (this as any).pg.$new({
        title: '',
        done: false,
      })
      this.dialog.mode = 'create'
      this.errorMessage = ''
      this.dialog.open = true
    },

    openEdit(item: any) {
      // Clone the model so cancel won't mutate the table immediately
      this.dialog.model = (this as any).pg.$new({
        id: item.id,
        title: item.title,
        done: item.done,
      })
      this.dialog.mode = 'edit'
      this.errorMessage = ''
      this.dialog.open = true
    },

    async submitDialog() {
      this.saving = true
      this.errorMessage = ''
      try {
        if (this.dialog.mode === 'create') {
          // POST create; return=minimal keeps model lightweight. 8
          await this.dialog.model.$post({ return: 'minimal' })
        } else {
          // PATCH update only changed columns when possible. 9
          await this.dialog.model.$patch()
        }
        this.closeDialog()
        this.refresh()
      } catch (e: any) {
        this.errorMessage = e?.message || 'Save failed'
      } finally {
        this.saving = false
      }
    },

    closeDialog() {
      this.dialog.open = false
      this.dialog.model = null
    },

    async saveInline(item: any) {
      // Toggle "done" via inline checkbox → PATCH the row. 10
      try {
        await item.$patch({ columns: ['done'] })
      } catch (e) {
        // revert UI if it fails
        item.done = !item.done
      }
    },

    confirmDelete(item: any) {
      this.confirm.item = item
      this.confirm.open = true
    },

    async doDelete() {
      if (!this.confirm.item) return
      this.saving = true
      try {
        await this.confirm.item.$delete()  // DELETE the row. 11
        this.confirm.open = false
        this.refresh()
      } catch (e) {
        // surface a toast/snackbar in a real app
      } finally {
        this.saving = false
      }
    },
  },
})
</script>

PostgREST side (example table)

-- example schema
CREATE TABLE public.todos(
  id serial PRIMARY KEY,
  title text NOT NULL,
  done boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now()
);

GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO anon;  -- or your web role

Why this works

vue-postgrest exposes a pg mixin that fetches a route (table/view) and returns a GenericCollection of GenericModels in this.pg. You can mutate model fields with v-model and persist via model.$patch(); create via pg.$new(...).$post(); delete via model.$delete().

query.order / query.select and other keys map directly to PostgREST query syntax (ordering, filtering).

count: 'exact' enables total counts via Content-Range, useful if you later add server-side pagination UI.

If you want server-side pagination with v-data-table (using headers for sort → passing to pgConfig.query.order, and using limit/offset with pg.$range.totalCount), I can wire that up too.