Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .storybook/storybook.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@
p:not(.sb-anchor, .sb-unstyled, .sb-unstyled p) {
font-weight: 300;
}

ul {
list-style: circle;
}

ol {
list-style: decimal;
}
}
}

Expand Down
90 changes: 48 additions & 42 deletions resources/js/components/ui/Listing/Listing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export const [injectListingContext, provideListingContext] = createContext('List
</script>

<script setup>
import { ref, toRef, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots } from 'vue';
import { ref, toRef, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue';

defineOptions({ inheritAttrs: false });

const attrs = useAttrs();
import useSkeletonDelay from '@/composables/skeleton-delay.js';
import {
Icon,
Expand Down Expand Up @@ -701,47 +705,49 @@ autoApplyState();
</script>

<template>
<slot name="initializing" v-if="shouldShowSkeleton">
<div class="flex flex-col gap-4 justify-between mt-3 starting-style-transition starting-style-transition--delay">
<ui-skeleton class="h-5 w-48" />
<div class="flex gap-2 sm:gap-3">
<ui-skeleton class="h-9 w-96" />
<ui-skeleton class="h-9 w-24" />
<div class="flex-1" />
<ui-skeleton class="size-10" />
<div v-bind="attrs">
<slot name="initializing" v-if="shouldShowSkeleton">
<div class="flex flex-col gap-4 justify-between mt-3 starting-style-transition starting-style-transition--delay">
<ui-skeleton class="h-5 w-48" />
<div class="flex gap-2 sm:gap-3">
<ui-skeleton class="h-9 w-96" />
<ui-skeleton class="h-9 w-24" />
<div class="flex-1" />
<ui-skeleton class="size-10" />
</div>
<ui-skeleton class="h-48 w-full" />
</div>
<ui-skeleton class="h-48 w-full" />
</div>
</slot>
<slot v-if="!initializing" :items="items" :is-column-visible="isColumnVisible" :loading="loading">
<Presets v-if="showPresets" />
<div v-if="allowSearch || hasFilters || allowCustomizingColumns" class="relative overflow-clip flex items-center gap-2 sm:gap-3 min-h-16 starting-style-transition st-overflow-clip-margin">
<div class="flex flex-1 items-center gap-2 sm:gap-3 w-full">
<Search v-if="allowSearch" />
<Filters v-if="hasFilters" />
</slot>
<slot v-if="!initializing" :items="items" :is-column-visible="isColumnVisible" :loading="loading">
<Presets v-if="showPresets" />
<div v-if="allowSearch || hasFilters || allowCustomizingColumns" class="relative overflow-clip flex items-center gap-2 sm:gap-3 min-h-16 starting-style-transition st-overflow-clip-margin">
<div class="flex flex-1 items-center gap-2 sm:gap-3 w-full">
<Search v-if="allowSearch" />
<Filters v-if="hasFilters" />
</div>
<CustomizeColumns v-if="allowCustomizingColumns" />
</div>
<CustomizeColumns v-if="allowCustomizingColumns" />
</div>

<div
v-if="!items.length"
class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-6 text-center text-gray-500"
v-text="__('No results')"
/>

<Panel v-else class="relative overflow-x-auto overscroll-x-contain" style="container-type: scroll-state;">
<Table>
<template v-for="(slot, slotName) in forwardedTableCellSlots" :key="slotName" #[slotName]="slotProps">
<component :is="slot" v-bind="slotProps" />
</template>
<template v-if="$slots['prepended-row-actions']" #prepended-row-actions="{ row }">
<slot name="prepended-row-actions" :row="row" />
</template>
</Table>
<PanelFooter v-if="meta">
<Pagination />
</PanelFooter>
</Panel>
</slot>
<BulkActions v-if="showBulkActions" />

<div
v-if="!items.length"
class="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-6 text-center text-gray-500"
v-text="__('No results')"
/>

<Panel v-else class="relative overflow-x-auto overscroll-x-contain" style="container-type: scroll-state;">
<Table>
<template v-for="(slot, slotName) in forwardedTableCellSlots" :key="slotName" #[slotName]="slotProps">
<component :is="slot" v-bind="slotProps" />
</template>
<template v-if="$slots['prepended-row-actions']" #prepended-row-actions="{ row }">
<slot name="prepended-row-actions" :row="row" />
</template>
</Table>
<PanelFooter v-if="meta">
<Pagination />
</PanelFooter>
</Panel>
</slot>
</div>
<BulkActions v-if="showBulkActions" />
</template>
65 changes: 64 additions & 1 deletion resources/js/stories/Listing.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
ListingTable,
ListingTableBody,
ListingTableHead,
ListingToggleAll
ListingToggleAll,
Panel,
PanelFooter,
} from '@ui';

const meta = {
Expand Down Expand Up @@ -208,4 +210,65 @@ export const _WithActions: Story = {
components: { Listing, Badge, DropdownItem },
template: actionsCode,
}),
};

const customLayoutCode = `
<Listing
:items="[
{ id: 1, name: 'Jack McDade', location: 'USA 🇺🇸', role: 'Founder' },
{ id: 2, name: 'Jason Varga', location: 'USA 🇺🇸', role: 'Lead Developer' },
{ id: 3, name: 'Joshua Blum', location: 'Germany 🇩🇪', role: 'Support' },
{ id: 4, name: 'Duncan McClean', location: 'Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', role: 'Developer' },
{ id: 5, name: 'Jay George', location: 'England 🏴󠁧󠁢󠁥󠁮󠁧󠁿️', role: 'Developer' },
{ id: 6, name: 'David Hasselhoff', location: 'USA 🇺🇸', role: 'The Hoff' },
]"
:columns="[
{ field: 'name', label: 'Name', sortable: true },
{ field: 'location', label: 'Location', sortable: true },
{ field: 'role', label: 'Role', sortable: true },
]"
v-slot="{ items, isColumnVisible, loading }"
>
<div class="relative flex flex-1 items-center gap-3">
<ListingSearch />
<ListingFilters />
<ListingCustomizeColumns />
</div>

<span v-if="!items.length" v-text="__('No results')" />

<Panel v-else>
<ListingTable>
<template #prepended-row-actions="{ row: entry }">
<DropdownItem text="Visit Profile" href="#" icon="eye" target="_blank" />
<DropdownItem text="Edit" href="#" icon="edit" />
</template>
</ListingTable>
</Panel>
</Listing>
`;

export const _CustomLayout: Story = {
tags: ['!dev'],
parameters: {
docs: {
source: { code: customLayoutCode }
}
},
render: () => ({
components: {
Listing,
ListingPresets,
ListingSearch,
ListingFilters,
ListingCustomizeColumns,
ListingTable,
ListingPagination,
Panel,
PanelFooter,
Badge,
DropdownItem,
},
template: customLayoutCode,
}),
};
147 changes: 146 additions & 1 deletion resources/js/stories/docs/Listing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,159 @@ A full-featured data listing component with sorting, searching, pagination, colu

## Customizing the cells
Customize how individual cells are rendered using cell slots. Use the pattern `#cell-{fieldName}` to target specific columns.

<Canvas of={ListingStories._CustomCells} sourceState={'shown'} />

The cell slots expose a few props:
- `row` - the current row's data
- `value` - the current column's value
- `isColumnVisible(column)` - determine whether a column is currently visible

## With Actions
You can add your own dropdown items via the `prepended-row-actions` slot.
<Canvas of={ListingStories._WithActions} sourceState={'shown'} />

## Customizing the layout
The `Listing` component includes a sensible default layout, but you can fully control the structure by providing your own markup in the default slot.

Inside the slot, you can compose your layout using the provided `Listing` sub-components, which automatically connect to the parent `Listing` via shared context.

If you need to customize table columns or row actions, you can do so inside the `ListingTable` component's slots.

<Canvas of={ListingStories._CustomLayout} sourceState={'shown'} />

### Available components
You can use the following components to build your own layout:

- `ListingCustomizeColumns`
- `ListingFilters`
- `ListingPagination`
- `ListingPresets`
- `ListingSearch`
- `ListingTable`

### Slot props
The default slot exposes a few props:

- `items` - the items for the current page
- `isColumnVisible(column)` - determine whether a column is currently visible
- `loading` - whether results are being fetched from the server

## Using a JSON endpoint
The `Listing` component can pull data from a JSON endpoint. Simply specify a `url` and return an [API Resource](https://laravel.com/docs/12.x/eloquent-resources#main-content) from your controller.
The `Listing` component can pull data from a JSON endpoint. It's a little more involved though...

1. You'll need to make a controller to query your data. The Listing component will provide `page`, `perPage`, `sort`, `order`, `search`, `columns` and `filters` as query parameters.

You may also want to make a [JSON resource](https://laravel.com/docs/master/eloquent-resources#main-content) to have more control over the response.

```php
use Illuminate\Http\Request;

public function json(Request $request)
{
$query = TeamMember::query();

if ($search $request->input('search')) {
$query->where('name', 'like', "%{$search}%");
}

$query->orderBy(
$request->input('sort', 'name'), // column
$request->input('order', 'asc') // direction
);

return TeamMemberResource::collection($query->paginate($request->input('perPage')));
}
```

2. Create a route and provide the `url` prop instead of `items`:

```php
Route::get('team-members/json', [TeamMemberController::class, 'json']);
```

```vue
<Listing
:url="cp_url('team-members/json')"
...
/>
```

3. Refresh your listing and hope for the best! 🎉

### Actions
To enable [actions](https://statamic.dev/backend-apis/actions), you'll need to make another controller. It should extend Statamic's `ActionController`.

The `$key` should match what's returned by [action's `visibleTo` method](https://statamic.dev/backend-apis/actions#filtering-actions). Inside `getSelectedItems`, you should lookup items via their IDs:

```php
<?php

namespace App\Http\Controllers;

use App\Models\TeamMember;
use Statamic\Http\Controllers\CP\ActionController as Controller;

class TeamMemberActionController extends Controller
{
protected static $key = 'team-members';

protected function getSelectedItems($items, $context)
{
return $items->map(function ($item) {
return TeamMember::find($item);
});
}
}
```

Next, add two routes pointing to the controller:

```php
Route::get('team-members/actions', [TeamMemberActionController::class, 'run']);
Route::get('team-members/actions/list', [TeamMemberActionController::class, 'bulkActions']);
```

Finally, pass the URL for the first route to the `actionUrl` prop:

```vue
<Listing
:url="cp_url('team-members/json')"
:action-url="cp_url('team-members/actions')"
...
/>
```

### Filtering
To enable filters on your listing, you'll need to call `Scope::filters()` in your controller and pass it to the `Listing` component on your page.

The `Scope::filters()` method expects a key (which should match what's returned by the [filter's `visibleTo` method](https://statamic.dev/backend-apis/query-scopes-and-filters#filters) along with any additional context you wish to provide.

```php
return Interia::render('team-members/Index', [
'filters' => Scope::filters('team-members', [
'company' => 'Statamic',
]),
// ...
]);
```

```vue
<script setup>
defineProps({
filters: Object,
// ...
});
</script>
<template>
<Listing
:url="cp_url('team-members/json')"
:filters="filters"
...
/>
</template>
```

## Arguments
<ArgTypes of={ListingStories} />